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