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 }