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