File: timer.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2024 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         // interesting: trying to filter out needlessly-chatty pipes
 172         // doesn't work as intended when done here, but is fine when
 173         // done at both receiving ends in the big select statement
 174         lines <- sc.Bytes()
 175     }
 176     return sc.Err()
 177 }
 178 
 179 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 180 // sources for lines are handled concurrently, one destined for the app's
 181 // stdout, the other for the app's stderr, without interfering with the
 182 // chronograph lines, which also show on stderr
 183 func chronograph(stdout io.Reader, stderr io.Reader) error {
 184     start := time.Now()
 185     t := time.NewTicker(100 * time.Millisecond)
 186     startChronoLine(start, start)
 187 
 188     stopped := make(chan os.Signal, 1)
 189     defer close(stopped)
 190     signal.Notify(stopped, os.Interrupt)
 191 
 192     errors := make(chan error)
 193     var waitAllLines sync.WaitGroup
 194     waitAllLines.Add(2)
 195 
 196     outLines := make(chan []byte)
 197     go func() {
 198         defer waitAllLines.Done()
 199         errors <- readLines(stdout, outLines)
 200     }()
 201 
 202     errLines := make(chan []byte)
 203     go func() {
 204         defer waitAllLines.Done()
 205         errors <- readLines(stderr, errLines)
 206     }()
 207 
 208     quit := make(chan struct{})
 209     defer close(quit)
 210 
 211     go func() {
 212         waitAllLines.Wait()
 213         close(errors)
 214         quit <- struct{}{}
 215     }()
 216 
 217     for {
 218         select {
 219         case now := <-t.C:
 220             os.Stderr.WriteString(clear)
 221             startChronoLine(start, now)
 222 
 223         case line := <-outLines:
 224             if line == nil {
 225                 // filter out junk for needlessly-chatty pipes, while still
 226                 // keeping actual empty lines
 227                 continue
 228             }
 229 
 230             // write-order of the next 3 steps matters, to avoid mixing
 231             // up lines since stdout and stderr lines can show up together
 232             os.Stderr.WriteString(clear)
 233             os.Stdout.Write(line)
 234             _, err := os.Stdout.WriteString("\n")
 235             startChronoLine(start, time.Now())
 236             if err != nil {
 237                 endChronoLine(start)
 238                 return err
 239             }
 240 
 241         case line := <-errLines:
 242             if line == nil {
 243                 // filter out junk for needlessly-chatty pipes, while still
 244                 // keeping actual empty lines
 245                 continue
 246             }
 247 
 248             // write-order of the next 3 steps matters, to avoid mixing
 249             // up lines since stdout and stderr lines can show up together
 250             os.Stderr.WriteString(clear)
 251             os.Stderr.Write(line)
 252             _, err := os.Stderr.WriteString("\n")
 253             startChronoLine(start, time.Now())
 254             if err != nil {
 255                 endChronoLine(start)
 256                 return err
 257             }
 258 
 259         case err := <-errors:
 260             if err == nil {
 261                 continue
 262             }
 263             os.Stderr.WriteString(clear)
 264             showError(err)
 265             startChronoLine(start, time.Now())
 266 
 267         case <-quit:
 268             endChronoLine(start)
 269             return justQuit{0}
 270 
 271         case <-stopped:
 272             t.Stop()
 273             endChronoLine(start)
 274             return justQuit{255}
 275         }
 276     }
 277 }
 278 
 279 func startChronoLine(start, now time.Time) {
 280     var buf [64]byte
 281     dt := now.Sub(start)
 282 
 283     os.Stderr.Write(time.Time{}.Add(dt).AppendFormat(buf[:0], `15:04:05`))
 284     os.Stderr.Write([]byte(`    `))
 285     os.Stderr.Write(now.AppendFormat(buf[:0], `2006-01-02 15:04:05 Jan Mon`))
 286 }
 287 
 288 func endChronoLine(start time.Time) {
 289     var buf [64]byte
 290     secs := time.Since(start).Seconds()
 291 
 292     os.Stderr.Write([]byte(`    `))
 293     os.Stderr.Write(strconv.AppendFloat(buf[:0], secs, 'f', 4, 64))
 294     os.Stderr.Write([]byte(" seconds\n"))
 295 }