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