File: timer/info.txt
   1 timer [command...] [args...]
   2 
   3 
   4 Run a live timer/chronograph on stderr, always showing below all lines
   5 from stdin, which update stdout as they come.
   6 
   7 When stdin is over, it simply quits showing how long it ran, by extending
   8 its last visible timer line on stderr.
   9 
  10 You can also use this app without piping anything to its stdin, acting as
  11 a simple live clock until you force-quit it.
  12 
  13 To ensure any stderr lines don't clash with the chronograph output line,
  14 you can also run this app with arguments to have it run a command, thus
  15 guaranteeing the subtask's stderr lines show without interference.

     File: timer/main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "io"
   6     "os"
   7     "os/exec"
   8     "os/signal"
   9     "strconv"
  10     "sync"
  11     "time"
  12 
  13     _ "embed"
  14 )
  15 
  16 // Note: the code is avoiding using the fmt package to save hundreds of
  17 // kilobytes on the resulting executable, which is a noticeable difference.
  18 
  19 const (
  20     spaces = `                `
  21 
  22     // clear has enough spaces in it to cover any chronograph output
  23     clear = "\r" + spaces + spaces + spaces + "\r"
  24 )
  25 
  26 //go:embed info.txt
  27 var info string
  28 
  29 func main() {
  30     if len(os.Args) > 1 {
  31         switch os.Args[1] {
  32         case `-h`, `--h`, `-help`, `--help`:
  33             os.Stderr.WriteString(info)
  34             return
  35         }
  36     }
  37 
  38     if len(os.Args) > 1 {
  39         os.Exit(runTask(os.Args[1], os.Args[2:]))
  40     }
  41 
  42     err := chronograph(os.Stdin, nil)
  43     if quit, ok := err.(justQuit); ok {
  44         os.Exit(quit.exitCode)
  45     }
  46 }
  47 
  48 // justQuit is a custom error-type which isn't for showing, but quitting the
  49 // app right away instead
  50 type justQuit struct {
  51     exitCode int
  52 }
  53 
  54 // Error is only to satisfy the error interface, and not for showing
  55 func (justQuit) Error() string {
  56     return `quitting right away`
  57 }
  58 
  59 // runTask handles running the app in `subtask-mode`
  60 func runTask(name string, args []string) (exitCode int) {
  61     cmd := exec.Command(name, args...)
  62     cmd.Stdin = os.Stdin
  63 
  64     stdout, err := cmd.StdoutPipe()
  65     if err != nil {
  66         showError(err)
  67         return 1
  68     }
  69     defer stdout.Close()
  70 
  71     stderr, err := cmd.StderrPipe()
  72     if err != nil {
  73         showError(err)
  74         return 1
  75     }
  76     defer stderr.Close()
  77 
  78     if err := cmd.Start(); err != nil {
  79         showError(err)
  80         return 1
  81     }
  82 
  83     err = chronograph(stdout, stderr)
  84     if quit, ok := err.(justQuit); ok {
  85         return quit.exitCode
  86     }
  87     if err != nil {
  88         showError(err)
  89     }
  90 
  91     if err := cmd.Wait(); err != nil {
  92         showError(err)
  93     }
  94     return cmd.ProcessState.ExitCode()
  95 }
  96 
  97 // showError gives a consistent style/look to any of the app's own errors
  98 func showError(err error) {
  99     if err == nil {
 100         return
 101     }
 102 
 103     os.Stderr.WriteString("\x1b[31m")
 104     os.Stderr.WriteString(err.Error())
 105     os.Stderr.WriteString("\x1b[0m\n")
 106 }
 107 
 108 // readLines is run twice asynchronously, so that both stdin and stderr lines
 109 // are handled independently, which matters when running a subtask
 110 func readLines(r io.Reader, lines chan []byte) error {
 111     defer close(lines)
 112 
 113     // when not handling a subtask, this func will be called with a nil
 114     // reader, since without a subtask, there's no stderr to read from
 115     if r == nil {
 116         return nil
 117     }
 118 
 119     const gb = 1024 * 1024 * 1024
 120     sc := bufio.NewScanner(r)
 121     sc.Buffer(nil, 8*gb)
 122 
 123     for sc.Scan() {
 124         // interesting: trying to filter out needlessly-chatty pipes
 125         // doesn't work as intended when done here, but is fine when
 126         // done at both receiving ends in the big select statement
 127         lines <- sc.Bytes()
 128     }
 129     return sc.Err()
 130 }
 131 
 132 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 133 // sources for lines are handled concurrently, one destined for the app's
 134 // stdout, the other for the app's stderr, without interfering with the
 135 // chronograph lines, which also show on stderr
 136 func chronograph(stdout io.Reader, stderr io.Reader) error {
 137     start := time.Now()
 138     t := time.NewTicker(100 * time.Millisecond)
 139     startChronoLine(start, start)
 140 
 141     stopped := make(chan os.Signal, 1)
 142     defer close(stopped)
 143     signal.Notify(stopped, os.Interrupt)
 144 
 145     errors := make(chan error)
 146     var waitAllLines sync.WaitGroup
 147     waitAllLines.Add(2)
 148 
 149     outLines := make(chan []byte)
 150     go func() {
 151         defer waitAllLines.Done()
 152         errors <- readLines(stdout, outLines)
 153     }()
 154 
 155     errLines := make(chan []byte)
 156     go func() {
 157         defer waitAllLines.Done()
 158         errors <- readLines(stderr, errLines)
 159     }()
 160 
 161     quit := make(chan struct{})
 162     defer close(quit)
 163 
 164     go func() {
 165         waitAllLines.Wait()
 166         close(errors)
 167         quit <- struct{}{}
 168     }()
 169 
 170     for {
 171         select {
 172         case now := <-t.C:
 173             os.Stderr.WriteString(clear)
 174             startChronoLine(start, now)
 175 
 176         case line := <-outLines:
 177             if line == nil {
 178                 // filter out junk for needlessly-chatty pipes, while still
 179                 // keeping actual empty lines
 180                 continue
 181             }
 182 
 183             // write-order of the next 3 steps matters, to avoid mixing
 184             // up lines since stdout and stderr lines can show up together
 185             os.Stderr.WriteString(clear)
 186             os.Stdout.Write(line)
 187             _, err := os.Stdout.WriteString("\n")
 188             startChronoLine(start, time.Now())
 189             if err != nil {
 190                 endChronoLine(start)
 191                 return err
 192             }
 193 
 194         case line := <-errLines:
 195             if line == nil {
 196                 // filter out junk for needlessly-chatty pipes, while still
 197                 // keeping actual empty lines
 198                 continue
 199             }
 200 
 201             // write-order of the next 3 steps matters, to avoid mixing
 202             // up lines since stdout and stderr lines can show up together
 203             os.Stderr.WriteString(clear)
 204             os.Stderr.Write(line)
 205             _, err := os.Stderr.WriteString("\n")
 206             startChronoLine(start, time.Now())
 207             if err != nil {
 208                 endChronoLine(start)
 209                 return err
 210             }
 211 
 212         case err := <-errors:
 213             if err == nil {
 214                 continue
 215             }
 216             os.Stderr.WriteString(clear)
 217             showError(err)
 218             startChronoLine(start, time.Now())
 219 
 220         case <-quit:
 221             endChronoLine(start)
 222             return justQuit{0}
 223 
 224         case <-stopped:
 225             t.Stop()
 226             endChronoLine(start)
 227             return justQuit{255}
 228         }
 229     }
 230 }
 231 
 232 func startChronoLine(start, now time.Time) {
 233     var buf [64]byte
 234     dt := now.Sub(start)
 235 
 236     os.Stderr.Write(time.Time{}.Add(dt).AppendFormat(buf[:0], `15:04:05`))
 237     os.Stderr.Write([]byte(`    `))
 238     os.Stderr.Write(now.AppendFormat(buf[:0], `2006-01-02 15:04:05 Jan Mon`))
 239 }
 240 
 241 func endChronoLine(start time.Time) {
 242     var buf [64]byte
 243     secs := time.Since(start).Seconds()
 244 
 245     os.Stderr.Write([]byte(`    `))
 246     os.Stderr.Write(strconv.AppendFloat(buf[:0], secs, 'f', 4, 64))
 247     os.Stderr.Write([]byte(" seconds\n"))
 248 }

     File: timer/mit-license.txt
   1 The MIT License (MIT)
   2 
   3 Copyright © 2024 pacman64
   4 
   5 Permission is hereby granted, free of charge, to any person obtaining a copy of
   6 this software and associated documentation files (the “Software”), to deal
   7 in the Software without restriction, including without limitation the rights to
   8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
   9 of the Software, and to permit persons to whom the Software is furnished to do
  10 so, subject to the following conditions:
  11 
  12 The above copyright notice and this permission notice shall be included in all
  13 copies or substantial portions of the Software.
  14 
  15 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21 SOFTWARE.