File: timer.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2020-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 Single-file source-code for timer, where the unit-tests from the original app
  27 are omitted.
  28 
  29 To compile a smaller-sized command-line app, you can use the `go` command as
  30 follows:
  31 
  32 go build -ldflags "-s -w" -trimpath timer.go
  33 */
  34 
  35 package main
  36 
  37 import (
  38     "bufio"
  39     "io"
  40     "os"
  41     "os/exec"
  42     "os/signal"
  43     "strconv"
  44     "sync"
  45     "time"
  46 )
  47 
  48 // Note: the code is avoiding using the fmt package to save hundreds of
  49 // kilobytes on the resulting executable, which is a noticeable difference.
  50 
  51 const (
  52     spaces = `                `
  53 
  54     // clear has enough spaces in it to cover any chronograph output
  55     clear = "\r" + spaces + spaces + spaces + "\r"
  56 )
  57 
  58 const info = `
  59 timer [command...] [args...]
  60 
  61 
  62 Run a live timer/chronograph on stderr, always showing below all lines
  63 from stdin, which update stdout as they come.
  64 
  65 When stdin is over, it simply quits showing how long it ran, by extending
  66 its last visible timer line on stderr.
  67 
  68 You can also use this app without piping anything to its stdin, acting as
  69 a simple live clock until you force-quit it.
  70 
  71 To ensure any stderr lines don't clash with the chronograph output line,
  72 you can also run this app with arguments to have it run a command, thus
  73 guaranteeing the subtask's stderr lines show without interference.
  74 `
  75 
  76 func main() {
  77     if len(os.Args) > 1 {
  78         switch os.Args[1] {
  79         case `-h`, `--h`, `-help`, `--help`:
  80             os.Stderr.WriteString(info[1:])
  81             return
  82         }
  83     }
  84 
  85     if len(os.Args) > 1 {
  86         os.Exit(runTask(os.Args[1], os.Args[2:]))
  87     }
  88 
  89     err := chronograph(os.Stdin, nil)
  90     if quit, ok := err.(justQuit); ok {
  91         os.Exit(quit.exitCode)
  92     }
  93 }
  94 
  95 // justQuit is a custom error-type which isn't for showing, but quitting the
  96 // app right away instead
  97 type justQuit struct {
  98     exitCode int
  99 }
 100 
 101 // Error is only to satisfy the error interface, and not for showing
 102 func (justQuit) Error() string {
 103     return `quitting right away`
 104 }
 105 
 106 // runTask handles running the app in `subtask-mode`
 107 func runTask(name string, args []string) (exitCode int) {
 108     cmd := exec.Command(name, args...)
 109     cmd.Stdin = os.Stdin
 110 
 111     stdout, err := cmd.StdoutPipe()
 112     if err != nil {
 113         showError(err)
 114         return 1
 115     }
 116     defer stdout.Close()
 117 
 118     stderr, err := cmd.StderrPipe()
 119     if err != nil {
 120         showError(err)
 121         return 1
 122     }
 123     defer stderr.Close()
 124 
 125     if err := cmd.Start(); err != nil {
 126         showError(err)
 127         return 1
 128     }
 129 
 130     err = chronograph(stdout, stderr)
 131     if quit, ok := err.(justQuit); ok {
 132         return quit.exitCode
 133     }
 134     if err != nil {
 135         showError(err)
 136     }
 137 
 138     if err := cmd.Wait(); err != nil {
 139         showError(err)
 140     }
 141     return cmd.ProcessState.ExitCode()
 142 }
 143 
 144 // showError gives a consistent style/look to any of the app's own errors
 145 func showError(err error) {
 146     if err == nil {
 147         return
 148     }
 149 
 150     os.Stderr.WriteString("\x1b[31m")
 151     os.Stderr.WriteString(err.Error())
 152     os.Stderr.WriteString("\x1b[0m\n")
 153 }
 154 
 155 // readLines is run twice asynchronously, so that both stdin and stderr lines
 156 // are handled independently, which matters when running a subtask
 157 func readLines(r io.Reader, lines chan []byte) error {
 158     defer close(lines)
 159 
 160     // when not handling a subtask, this func will be called with a nil
 161     // reader, since without a subtask, there's no stderr to read from
 162     if r == nil {
 163         return nil
 164     }
 165 
 166     const gb = 1024 * 1024 * 1024
 167     sc := bufio.NewScanner(r)
 168     sc.Buffer(nil, 8*gb)
 169 
 170     for sc.Scan() {
 171         lines <- sc.Bytes()
 172     }
 173     return sc.Err()
 174 }
 175 
 176 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 177 // sources for lines are handled concurrently, one destined for the app's
 178 // stdout, the other for the app's stderr, without interfering with the
 179 // chronograph lines, which also show on stderr
 180 func chronograph(stdout io.Reader, stderr io.Reader) error {
 181     start := time.Now()
 182     t := time.NewTicker(100 * time.Millisecond)
 183     startChronoLine(start, start)
 184 
 185     stopped := make(chan os.Signal, 1)
 186     defer close(stopped)
 187     signal.Notify(stopped, os.Interrupt)
 188 
 189     errors := make(chan error)
 190     var waitAllLines sync.WaitGroup
 191     waitAllLines.Add(2)
 192 
 193     outLines := make(chan []byte)
 194     go func() {
 195         defer waitAllLines.Done()
 196         if stdout != nil {
 197             errors <- readLines(stdout, outLines)
 198         }
 199     }()
 200 
 201     errLines := make(chan []byte)
 202     go func() {
 203         defer waitAllLines.Done()
 204         if stderr != nil {
 205             errors <- readLines(stderr, errLines)
 206         }
 207     }()
 208 
 209     quit := make(chan struct{})
 210     defer close(quit)
 211 
 212     go func() {
 213         waitAllLines.Wait()
 214         close(errors)
 215         quit <- struct{}{}
 216     }()
 217 
 218     for {
 219         select {
 220         case now := <-t.C:
 221             os.Stderr.WriteString(clear)
 222             startChronoLine(start, now)
 223 
 224         case line := <-outLines:
 225             if line == nil {
 226                 // filter out junk for needlessly-chatty pipes, while still
 227                 // keeping actual empty lines
 228                 continue
 229             }
 230 
 231             // write-order of the next 3 steps matters, to avoid mixing
 232             // up lines since stdout and stderr lines can show up together
 233             os.Stderr.WriteString(clear)
 234             os.Stdout.Write(line)
 235             _, err := os.Stdout.WriteString("\n")
 236             startChronoLine(start, time.Now())
 237             if err != nil {
 238                 endChronoLine(start)
 239                 return nil
 240             }
 241 
 242         case line := <-errLines:
 243             if line == nil {
 244                 // filter out junk for needlessly-chatty pipes, while still
 245                 // keeping actual empty lines
 246                 continue
 247             }
 248 
 249             // write-order of the next 3 steps matters, to avoid mixing
 250             // up lines since stdout and stderr lines can show up together
 251             os.Stderr.WriteString(clear)
 252             os.Stderr.Write(line)
 253             _, err := os.Stderr.WriteString("\n")
 254             startChronoLine(start, time.Now())
 255             if err != nil {
 256                 endChronoLine(start)
 257                 return nil
 258             }
 259 
 260         case err := <-errors:
 261             if err == nil {
 262                 continue
 263             }
 264             os.Stderr.WriteString(clear)
 265             showError(err)
 266             startChronoLine(start, time.Now())
 267 
 268         case <-quit:
 269             endChronoLine(start)
 270             return justQuit{0}
 271 
 272         case <-stopped:
 273             t.Stop()
 274             endChronoLine(start)
 275             return justQuit{255}
 276         }
 277     }
 278 }
 279 
 280 // func startChronoLine(start, now time.Time) {
 281 //  var buf [64]byte
 282 //  dt := now.Sub(start)
 283 //
 284 //  os.Stderr.Write(time.Time{}.Add(dt).AppendFormat(buf[:0], `15:04:05.0`))
 285 //  os.Stderr.WriteString(`    `)
 286 //  os.Stderr.Write(now.AppendFormat(buf[:0], `2006-01-02 15:04:05 Jan Mon`))
 287 // }
 288 
 289 func startChronoLine(start, now time.Time) {
 290     var buf [64]byte
 291     dt := now.Sub(start)
 292 
 293     s := buf[:0]
 294     s = time.Time{}.Add(dt).AppendFormat(s, `15:04:05.0`)
 295     s = append(s, `    `...)
 296     s = now.AppendFormat(s, `2006-01-02 15:04:05 Jan Mon`)
 297     os.Stderr.Write(s)
 298 }
 299 
 300 func endChronoLine(start time.Time) {
 301     var buf [64]byte
 302     secs := time.Since(start).Seconds()
 303 
 304     os.Stderr.Write([]byte(`    `))
 305     os.Stderr.Write(strconv.AppendFloat(buf[:0], secs, 'f', 4, 64))
 306     os.Stderr.Write([]byte(" seconds\n"))
 307 }