File: timer.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the "Software"), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath timer.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "bytes"
  37     "io"
  38     "os"
  39     "os/exec"
  40     "os/signal"
  41     "strconv"
  42     "sync"
  43     "time"
  44 )
  45 
  46 const (
  47     // gap has the spaces between current timer value and current date/time
  48     gap = `    `
  49 
  50     // clear has enough spaces in it to cover any chronograph output
  51     clear = "\r" + `                                              ` + "\r"
  52 
  53     dateTimeFormat = `2006-01-02 15:04:05 Jan Mon`
  54 
  55     forceQuitCode = 255
  56 )
  57 
  58 const (
  59     // every = 100 * time.Millisecond
  60     // chronoFormat = `15:04:05.0`
  61 
  62     every        = 1000 * time.Millisecond
  63     chronoFormat = `15:04:05`
  64 )
  65 
  66 const info = `
  67 timer [options...] [command...] [args...]
  68 
  69 
  70 Run a live timer/chronograph on stderr, always showing below all lines
  71 from stdin, which update stdout as they come.
  72 
  73 When stdin (or the command run) is over, this app simply quits showing how
  74 long it ran, by extending its last visible timer line on stderr.
  75 
  76 You can also use this app without piping anything to its stdin or giving it
  77 any command to run, acting as a simple live clock until you force-quit it,
  78 via Control+C, or end the stdin stream via Control+D.
  79 
  80 To ensure any stderr lines don't clash with the chronograph output line,
  81 you can also run this app with arguments to have it run a command, thus
  82 guaranteeing the subtask's stdout and stderr lines show without ever being
  83 mixed up.
  84 
  85 Note: by default this app assumes command output is plain-text lines, so a
  86 command's non-empty binary stdout may be corrupted in subtle ways; use the
  87 binary option to avoid that.
  88 
  89 The options are, available both in single and double-dash versions
  90 
  91     -b, -bin, -binary    treat stdin or a command's stdout as binary data
  92     -h, -help            show this help message
  93 `
  94 
  95 func main() {
  96     bin := false
  97     args := os.Args[1:]
  98 
  99     if len(args) > 0 {
 100         switch args[0] {
 101         case `-h`, `--h`, `-help`, `--help`:
 102             os.Stdout.WriteString(info[1:])
 103             return
 104 
 105         case `-b`, `--b`, `-bin`, `--bin`, `-binary`, `--binary`:
 106             bin = true
 107             args = args[1:]
 108         }
 109     }
 110 
 111     if len(args) > 0 && args[0] == `--` {
 112         args = args[1:]
 113     }
 114 
 115     if len(args) > 0 {
 116         os.Exit(runTask(args[0], args[1:], bin))
 117         return
 118     }
 119 
 120     err := chronograph(os.Stdin, nil, bin)
 121     if exit, ok := err.(exit); ok {
 122         os.Exit(exit.code)
 123         return
 124     }
 125 
 126     if err != nil {
 127         showError(err)
 128         os.Exit(1)
 129         return
 130     }
 131 }
 132 
 133 // exit is a custom error-type which isn't for showing, but for quitting the
 134 // app right away instead
 135 type exit struct {
 136     code int
 137 }
 138 
 139 // Error is only to satisfy the error interface, and not for showing
 140 func (e exit) Error() string {
 141     os.Exit(e.code)
 142     return `quitting right away`
 143 }
 144 
 145 // runTask handles running the app in `subtask-mode`
 146 func runTask(name string, args []string, bin bool) (exitCode int) {
 147     cmd := exec.Command(name, args...)
 148     cmd.Stdin = os.Stdin
 149 
 150     stdout, err := cmd.StdoutPipe()
 151     if err != nil {
 152         showError(err)
 153         return 1
 154     }
 155     defer stdout.Close()
 156 
 157     stderr, err := cmd.StderrPipe()
 158     if err != nil {
 159         showError(err)
 160         return 1
 161     }
 162     defer stderr.Close()
 163 
 164     if err := cmd.Start(); err != nil {
 165         showError(err)
 166         return 1
 167     }
 168 
 169     err = chronograph(stdout, stderr, bin)
 170     if exit, ok := err.(exit); ok {
 171         return exit.code
 172     }
 173     if err != nil {
 174         showError(err)
 175     }
 176 
 177     if err := cmd.Wait(); err != nil {
 178         showError(err)
 179     }
 180     return cmd.ProcessState.ExitCode()
 181 }
 182 
 183 func showError(err error) {
 184     if err != nil {
 185         os.Stderr.WriteString(err.Error())
 186         os.Stderr.WriteString("\n")
 187     }
 188 }
 189 
 190 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 191 // sources for lines are handled concurrently, one destined for the app's
 192 // stdout, the other for the app's stderr, without interfering with the
 193 // chronograph lines, which also show on stderr
 194 func chronograph(stdout io.Reader, stderr io.Reader, binaryOut bool) error {
 195     start := time.Now()
 196     t := time.NewTicker(every)
 197     // s is a buffer used to minimize actual writes to final stdout/stderr
 198     s := make([]byte, 0, 1024)
 199 
 200     // start showing the timer right away
 201     s = startChronoLine(s[:0], start, start)
 202     os.Stderr.Write(s)
 203 
 204     // stopped is a special event to handle force-quitting the app
 205     stopped := make(chan os.Signal, 1)
 206     defer close(stopped)
 207     signal.Notify(stopped, os.Interrupt)
 208 
 209     // errors will no longer travel when both input streams are over
 210     errors := make(chan error)
 211     var waitAllOutput sync.WaitGroup
 212     waitAllOutput.Add(2)
 213 
 214     // binary-mode only concerns stdin or stdout, and never stderr
 215     readOutput := readLines
 216     if binaryOut {
 217         readOutput = readBytes
 218     }
 219 
 220     // relay stdout data asynchronously
 221     outChunks := make(chan []byte)
 222     go func() {
 223         defer waitAllOutput.Done()
 224         if stdout != nil {
 225             errors <- readOutput(stdout, outChunks)
 226         }
 227     }()
 228 
 229     // relay stderr data asynchronously
 230     errLines := make(chan []byte)
 231     go func() {
 232         defer waitAllOutput.Done()
 233         if stderr != nil {
 234             errors <- readLines(stderr, errLines)
 235         }
 236     }()
 237 
 238     // quit is a special event which happens when both input streams are over
 239     quit := make(chan struct{})
 240     defer close(quit)
 241     go func() {
 242         waitAllOutput.Wait()
 243         close(errors)
 244         quit <- struct{}{}
 245     }()
 246 
 247     for {
 248         select {
 249         case now := <-t.C:
 250             s = append(s[:0], clear...)
 251             s = startChronoLine(s, start, now)
 252             os.Stderr.Write(s)
 253 
 254         case chunk := <-outChunks:
 255             if chunk == nil {
 256                 // filter out junk for needlessly-chatty pipes, while still
 257                 // keeping actual empty lines
 258                 continue
 259             }
 260 
 261             // write-order of the next 3 steps matters, to avoid mixing up
 262             // lines since stdout and stderr lines can show up together, if
 263             // not handled correctly
 264 
 265             os.Stderr.WriteString(clear)
 266 
 267             s = append(s[:0], chunk...)
 268             // add an extra line-feed byte only when in plain-text mode
 269             if !binaryOut {
 270                 s = append(s, '\n')
 271             }
 272 
 273             // assume (final) stdout write errors are due to a closed pipe,
 274             // so quit this app successfully right away
 275             if _, err := os.Stdout.Write(s); err != nil {
 276                 t.Stop()
 277                 s = startChronoLine(s[:0], start, time.Now())
 278                 s = endChronoLine(s, start)
 279                 os.Stderr.Write(s)
 280                 return nil
 281             }
 282 
 283             s = startChronoLine(s[:0], start, time.Now())
 284             os.Stderr.Write(s)
 285 
 286         case line := <-errLines:
 287             if line == nil {
 288                 // filter out junk for needlessly-chatty pipes, while still
 289                 // keeping actual empty lines
 290                 continue
 291             }
 292 
 293             s = append(append(append(s[:0], clear...), line...), '\n')
 294             s = startChronoLine(s, start, time.Now())
 295             s = endChronoLine(s, start)
 296             os.Stderr.Write(s)
 297 
 298         case err := <-errors:
 299             if err == nil {
 300                 continue
 301             }
 302             os.Stderr.WriteString(clear)
 303             showError(err)
 304             s = startChronoLine(s[:0], start, time.Now())
 305             os.Stderr.Write(s)
 306 
 307         case <-quit:
 308             os.Stderr.Write(endChronoLine(s[:0], start))
 309             return exit{0}
 310 
 311         case <-stopped:
 312             t.Stop()
 313             os.Stderr.Write(endChronoLine(s[:0], start))
 314             return exit{forceQuitCode}
 315         }
 316     }
 317 }
 318 
 319 // readBytes is the binary-mode alternative to func readLines for a command's
 320 // standard output
 321 func readBytes(r io.Reader, chunks chan<- []byte) error {
 322     defer close(chunks)
 323 
 324     if r == nil {
 325         return nil
 326     }
 327 
 328     var buf [16 * 1024]byte
 329 
 330     for {
 331         n, err := r.Read(buf[:])
 332 
 333         if err == io.EOF {
 334             if n > 0 {
 335                 chunks <- buf[:n]
 336             }
 337             return nil
 338         }
 339 
 340         if err != nil {
 341             return err
 342         }
 343 
 344         if n < 1 {
 345             continue
 346         }
 347 
 348         chunks <- buf[:n]
 349     }
 350 }
 351 
 352 // readLines is run twice asynchronously, so that both stdin and stderr lines
 353 // are handled independently, which matters when running a subtask
 354 func readLines(r io.Reader, lines chan<- []byte) error {
 355     defer close(lines)
 356 
 357     // when not handling a subtask, this func will be called with a nil
 358     // reader, since without a subtask, there's no stderr to read from
 359     if r == nil {
 360         return nil
 361     }
 362 
 363     const gb = 1024 * 1024 * 1024
 364     sc := bufio.NewScanner(r)
 365     sc.Buffer(nil, 8*gb)
 366 
 367     for i := 0; sc.Scan(); i++ {
 368         s := sc.Bytes()
 369         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 370             s = s[3:]
 371         }
 372         lines <- s
 373     }
 374     return sc.Err()
 375 }
 376 
 377 func startChronoLine(buf []byte, start, now time.Time) []byte {
 378     dt := now.Sub(start)
 379     buf = time.Time{}.Add(dt).AppendFormat(buf, chronoFormat)
 380     buf = append(buf, gap...)
 381     buf = now.AppendFormat(buf, dateTimeFormat)
 382     return buf
 383 }
 384 
 385 func endChronoLine(buf []byte, start time.Time) []byte {
 386     secs := time.Since(start).Seconds()
 387     buf = append(buf, gap...)
 388     buf = strconv.AppendFloat(buf, secs, 'f', 4, 64)
 389     buf = append(buf, " seconds\n"...)
 390     return buf
 391 }