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     }
 118 
 119     err := chronograph(os.Stdin, nil, bin)
 120     if exit, ok := err.(exit); ok {
 121         os.Exit(exit.code)
 122     }
 123 
 124     if err != nil {
 125         showError(err)
 126         os.Exit(1)
 127     }
 128 }
 129 
 130 // exit is a custom error-type which isn't for showing, but for quitting the
 131 // app right away instead
 132 type exit struct {
 133     code int
 134 }
 135 
 136 // Error is only to satisfy the error interface, and not for showing
 137 func (e exit) Error() string {
 138     os.Exit(e.code)
 139     return `quitting right away`
 140 }
 141 
 142 // runTask handles running the app in `subtask-mode`
 143 func runTask(name string, args []string, bin bool) (exitCode int) {
 144     cmd := exec.Command(name, args...)
 145     cmd.Stdin = os.Stdin
 146 
 147     stdout, err := cmd.StdoutPipe()
 148     if err != nil {
 149         showError(err)
 150         return 1
 151     }
 152     defer stdout.Close()
 153 
 154     stderr, err := cmd.StderrPipe()
 155     if err != nil {
 156         showError(err)
 157         return 1
 158     }
 159     defer stderr.Close()
 160 
 161     if err := cmd.Start(); err != nil {
 162         showError(err)
 163         return 1
 164     }
 165 
 166     err = chronograph(stdout, stderr, bin)
 167     if exit, ok := err.(exit); ok {
 168         return exit.code
 169     }
 170     if err != nil {
 171         showError(err)
 172     }
 173 
 174     if err := cmd.Wait(); err != nil {
 175         showError(err)
 176     }
 177     return cmd.ProcessState.ExitCode()
 178 }
 179 
 180 func showError(err error) {
 181     if err != nil {
 182         os.Stderr.WriteString(err.Error())
 183         os.Stderr.WriteString("\n")
 184     }
 185 }
 186 
 187 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 188 // sources for lines are handled concurrently, one destined for the app's
 189 // stdout, the other for the app's stderr, without interfering with the
 190 // chronograph lines, which also show on stderr
 191 func chronograph(stdout io.Reader, stderr io.Reader, binaryOut bool) error {
 192     start := time.Now()
 193     t := time.NewTicker(every)
 194     // s is a buffer used to minimize actual writes to final stdout/stderr
 195     s := make([]byte, 0, 1024)
 196 
 197     // start showing the timer right away
 198     s = startChronoLine(s[:0], start, start)
 199     os.Stderr.Write(s)
 200 
 201     // stopped is a special event to handle force-quitting the app
 202     stopped := make(chan os.Signal, 1)
 203     defer close(stopped)
 204     signal.Notify(stopped, os.Interrupt)
 205 
 206     // errors will no longer travel when both input streams are over
 207     errors := make(chan error)
 208     var waitAllOutput sync.WaitGroup
 209     waitAllOutput.Add(2)
 210 
 211     // binary-mode only concerns stdin or stdout, and never stderr
 212     readOutput := readLines
 213     if binaryOut {
 214         readOutput = readBytes
 215     }
 216 
 217     // relay stdout data asynchronously
 218     outChunks := make(chan []byte)
 219     go func() {
 220         defer waitAllOutput.Done()
 221         if stdout != nil {
 222             errors <- readOutput(stdout, outChunks)
 223         }
 224     }()
 225 
 226     // relay stderr data asynchronously
 227     errLines := make(chan []byte)
 228     go func() {
 229         defer waitAllOutput.Done()
 230         if stderr != nil {
 231             errors <- readLines(stderr, errLines)
 232         }
 233     }()
 234 
 235     // quit is a special event which happens when both input streams are over
 236     quit := make(chan struct{})
 237     defer close(quit)
 238     go func() {
 239         waitAllOutput.Wait()
 240         close(errors)
 241         quit <- struct{}{}
 242     }()
 243 
 244     for {
 245         select {
 246         case now := <-t.C:
 247             s = append(s[:0], clear...)
 248             s = startChronoLine(s, start, now)
 249             os.Stderr.Write(s)
 250 
 251         case chunk := <-outChunks:
 252             if chunk == nil {
 253                 // filter out junk for needlessly-chatty pipes, while still
 254                 // keeping actual empty lines
 255                 continue
 256             }
 257 
 258             // write-order of the next 3 steps matters, to avoid mixing up
 259             // lines since stdout and stderr lines can show up together, if
 260             // not handled correctly
 261 
 262             os.Stderr.WriteString(clear)
 263 
 264             s = append(s[:0], chunk...)
 265             // add an extra line-feed byte only when in plain-text mode
 266             if !binaryOut {
 267                 s = append(s, '\n')
 268             }
 269 
 270             // assume (final) stdout write errors are due to a closed pipe,
 271             // so quit this app successfully right away
 272             if _, err := os.Stdout.Write(s); err != nil {
 273                 t.Stop()
 274                 s = startChronoLine(s[:0], start, time.Now())
 275                 s = endChronoLine(s, start)
 276                 os.Stderr.Write(s)
 277                 return nil
 278             }
 279 
 280             s = startChronoLine(s[:0], start, time.Now())
 281             os.Stderr.Write(s)
 282 
 283         case line := <-errLines:
 284             if line == nil {
 285                 // filter out junk for needlessly-chatty pipes, while still
 286                 // keeping actual empty lines
 287                 continue
 288             }
 289 
 290             s = append(append(append(s[:0], clear...), line...), '\n')
 291             s = startChronoLine(s, start, time.Now())
 292             s = endChronoLine(s, start)
 293             os.Stderr.Write(s)
 294 
 295         case err := <-errors:
 296             if err == nil {
 297                 continue
 298             }
 299             os.Stderr.WriteString(clear)
 300             showError(err)
 301             s = startChronoLine(s[:0], start, time.Now())
 302             os.Stderr.Write(s)
 303 
 304         case <-quit:
 305             os.Stderr.Write(endChronoLine(s[:0], start))
 306             return exit{0}
 307 
 308         case <-stopped:
 309             t.Stop()
 310             os.Stderr.Write(endChronoLine(s[:0], start))
 311             return exit{forceQuitCode}
 312         }
 313     }
 314 }
 315 
 316 // readBytes is the binary-mode alternative to func readLines for a command's
 317 // standard output
 318 func readBytes(r io.Reader, chunks chan<- []byte) error {
 319     defer close(chunks)
 320 
 321     if r == nil {
 322         return nil
 323     }
 324 
 325     var buf [16 * 1024]byte
 326 
 327     for {
 328         n, err := r.Read(buf[:])
 329 
 330         if err == io.EOF {
 331             if n > 0 {
 332                 chunks <- buf[:n]
 333             }
 334             return nil
 335         }
 336 
 337         if err != nil {
 338             return err
 339         }
 340 
 341         if n < 1 {
 342             continue
 343         }
 344 
 345         chunks <- buf[:n]
 346     }
 347 }
 348 
 349 // readLines is run twice asynchronously, so that both stdin and stderr lines
 350 // are handled independently, which matters when running a subtask
 351 func readLines(r io.Reader, lines chan<- []byte) error {
 352     defer close(lines)
 353 
 354     // when not handling a subtask, this func will be called with a nil
 355     // reader, since without a subtask, there's no stderr to read from
 356     if r == nil {
 357         return nil
 358     }
 359 
 360     const gb = 1024 * 1024 * 1024
 361     sc := bufio.NewScanner(r)
 362     sc.Buffer(nil, 8*gb)
 363 
 364     for i := 0; sc.Scan(); i++ {
 365         s := sc.Bytes()
 366         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 367             s = s[3:]
 368         }
 369         lines <- s
 370     }
 371     return sc.Err()
 372 }
 373 
 374 func startChronoLine(buf []byte, start, now time.Time) []byte {
 375     dt := now.Sub(start)
 376     buf = time.Time{}.Add(dt).AppendFormat(buf, chronoFormat)
 377     buf = append(buf, gap...)
 378     buf = now.AppendFormat(buf, dateTimeFormat)
 379     return buf
 380 }
 381 
 382 func endChronoLine(buf []byte, start time.Time) []byte {
 383     secs := time.Since(start).Seconds()
 384     buf = append(buf, gap...)
 385     buf = strconv.AppendFloat(buf, secs, 'f', 4, 64)
 386     buf = append(buf, " seconds\n"...)
 387     return buf
 388 }