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 forceQuitCode = 255 58 ) 59 60 const ( 61 // every = 100 * time.Millisecond 62 // chronoFormat = `15:04:05.0` 63 64 // every = 500 * time.Millisecond 65 // chronoFormat = `15:04:05` 66 67 every = 1000 * time.Millisecond 68 chronoFormat = `15:04:05` 69 ) 70 71 const info = ` 72 timer [options...] [command...] [args...] 73 74 75 Run a live timer/chronograph on stderr, always showing below all lines 76 from stdin, which update stdout as they come. 77 78 When stdin (or the command run) is over, this app simply quits showing how 79 long it ran, by extending its last visible timer line on stderr. 80 81 You can also use this app without piping anything to its stdin or giving it 82 any command to run, acting as a simple live clock until you force-quit it, 83 via Control+C, or end the stdin stream via Control+D. 84 85 To ensure any stderr lines don't clash with the chronograph output line, 86 you can also run this app with arguments to have it run a command, thus 87 guaranteeing the subtask's stdout and stderr lines show without ever being 88 mixed up. 89 90 Note: by default this app assumes command output is plain-text lines, so a 91 command's non-empty binary stdout may be corrupted in subtle ways; use the 92 binary option to avoid that. 93 94 The options are, available both in single and double-dash versions 95 96 -b treat stdin or a command's stdout as generic binary data 97 -bin treat stdin or a command's stdout as generic binary data 98 -binary treat stdin or a command's stdout as generic binary data 99 100 -h show this help message 101 -help show this help message 102 ` 103 104 func main() { 105 bin := false 106 args := os.Args[1:] 107 108 if len(args) > 0 { 109 switch args[0] { 110 case `-h`, `--h`, `-help`, `--help`: 111 os.Stderr.WriteString(info[1:]) 112 return 113 114 case `-b`, `--b`, `-bin`, `--bin`, `-binary`, `--binary`: 115 bin = true 116 args = args[1:] 117 } 118 } 119 120 if len(args) > 0 && args[0] == `--` { 121 args = args[1:] 122 } 123 124 if len(args) > 0 { 125 os.Exit(runTask(args[0], args[1:], bin)) 126 } 127 128 err := chronograph(os.Stdin, nil, bin) 129 if quit, ok := err.(justQuit); ok { 130 os.Exit(quit.exitCode) 131 } 132 133 if err != nil { 134 showError(err) 135 os.Exit(1) 136 } 137 } 138 139 // justQuit is a custom error-type which isn't for showing, but for quitting 140 // the app right away instead 141 type justQuit struct { 142 exitCode int 143 } 144 145 // Error is only to satisfy the error interface, and not for showing 146 func (justQuit) Error() string { 147 return `quitting right away` 148 } 149 150 // runTask handles running the app in `subtask-mode` 151 func runTask(name string, args []string, bin bool) (exitCode int) { 152 cmd := exec.Command(name, args...) 153 cmd.Stdin = os.Stdin 154 155 stdout, err := cmd.StdoutPipe() 156 if err != nil { 157 showError(err) 158 return 1 159 } 160 defer stdout.Close() 161 162 stderr, err := cmd.StderrPipe() 163 if err != nil { 164 showError(err) 165 return 1 166 } 167 defer stderr.Close() 168 169 if err := cmd.Start(); err != nil { 170 showError(err) 171 return 1 172 } 173 174 err = chronograph(stdout, stderr, bin) 175 if quit, ok := err.(justQuit); ok { 176 return quit.exitCode 177 } 178 if err != nil { 179 showError(err) 180 } 181 182 if err := cmd.Wait(); err != nil { 183 showError(err) 184 } 185 return cmd.ProcessState.ExitCode() 186 } 187 188 func showError(err error) { 189 if err != nil { 190 os.Stderr.WriteString(err.Error()) 191 os.Stderr.WriteString("\n") 192 } 193 } 194 195 // readBytes is the binary-mode alternative to func readLines for a command's 196 // standard output 197 func readBytes(r io.Reader, chunks chan []byte) error { 198 defer close(chunks) 199 200 if r == nil { 201 return nil 202 } 203 204 var buf [16 * 1024]byte 205 206 for { 207 n, err := r.Read(buf[:]) 208 209 if err == io.EOF { 210 if n > 0 { 211 chunks <- buf[:n] 212 } 213 return nil 214 } 215 216 if err != nil { 217 return err 218 } 219 220 if n < 1 { 221 continue 222 } 223 224 chunks <- buf[:n] 225 } 226 } 227 228 // readLines is run twice asynchronously, so that both stdin and stderr lines 229 // are handled independently, which matters when running a subtask 230 func readLines(r io.Reader, lines chan []byte) error { 231 defer close(lines) 232 233 // when not handling a subtask, this func will be called with a nil 234 // reader, since without a subtask, there's no stderr to read from 235 if r == nil { 236 return nil 237 } 238 239 const gb = 1024 * 1024 * 1024 240 sc := bufio.NewScanner(r) 241 sc.Buffer(nil, 8*gb) 242 243 for sc.Scan() { 244 lines <- sc.Bytes() 245 } 246 return sc.Err() 247 } 248 249 // chronograph runs a live chronograph, showing the time elapsed: 2 input 250 // sources for lines are handled concurrently, one destined for the app's 251 // stdout, the other for the app's stderr, without interfering with the 252 // chronograph lines, which also show on stderr 253 func chronograph(stdout io.Reader, stderr io.Reader, bin bool) error { 254 start := time.Now() 255 t := time.NewTicker(every) 256 // s is a buffer used to minimize actual writes to final stdout/stderr 257 s := make([]byte, 0, 1024) 258 259 // start showing the timer right away 260 s = startChronoLine(s[:0], start, start) 261 os.Stderr.Write(s) 262 263 // stopped is a special event to handle force-quitting the app 264 stopped := make(chan os.Signal, 1) 265 defer close(stopped) 266 signal.Notify(stopped, os.Interrupt) 267 268 // errors will no longer travel when both input streams are over 269 errors := make(chan error) 270 var waitAllOutput sync.WaitGroup 271 waitAllOutput.Add(2) 272 273 // binary-mode only concerns stdin or stdout, and never stderr 274 readOutput := readLines 275 if bin { 276 readOutput = readBytes 277 } 278 279 // relay stdout data asynchronously 280 outChunks := make(chan []byte) 281 go func() { 282 defer waitAllOutput.Done() 283 if stdout != nil { 284 errors <- readOutput(stdout, outChunks) 285 } 286 }() 287 288 // relay stderr data asynchronously 289 errLines := make(chan []byte) 290 go func() { 291 defer waitAllOutput.Done() 292 if stderr != nil { 293 errors <- readLines(stderr, errLines) 294 } 295 }() 296 297 // quit is a special event which happens when both input streams are over 298 quit := make(chan struct{}) 299 defer close(quit) 300 go func() { 301 waitAllOutput.Wait() 302 close(errors) 303 quit <- struct{}{} 304 }() 305 306 for { 307 select { 308 case now := <-t.C: 309 s = append(s[:0], clear...) 310 s = startChronoLine(s, start, now) 311 os.Stderr.Write(s) 312 313 case chunk := <-outChunks: 314 if chunk == nil { 315 // filter out junk for needlessly-chatty pipes, while still 316 // keeping actual empty lines 317 continue 318 } 319 320 // write-order of the next 3 steps matters, to avoid mixing up 321 // lines since stdout and stderr lines can show up together, if 322 // not handled correctly 323 324 os.Stderr.WriteString(clear) 325 326 s = append(s[:0], chunk...) 327 // add an extra line-feed byte only when in plain-text mode 328 if !bin { 329 s = append(s, '\n') 330 } 331 332 // assume (final) stdout write errors are due to a closed pipe, 333 // so quit this app successfully right away 334 if _, err := os.Stdout.Write(s); err != nil { 335 t.Stop() 336 s = startChronoLine(s[:0], start, time.Now()) 337 s = endChronoLine(s, start) 338 os.Stderr.Write(s) 339 return nil 340 } 341 342 s = startChronoLine(s[:0], start, time.Now()) 343 os.Stderr.Write(s) 344 345 case line := <-errLines: 346 if line == nil { 347 // filter out junk for needlessly-chatty pipes, while still 348 // keeping actual empty lines 349 continue 350 } 351 352 s = append(append(append(s[:0], clear...), line...), '\n') 353 s = startChronoLine(s, start, time.Now()) 354 s = endChronoLine(s, start) 355 os.Stderr.Write(s) 356 357 case err := <-errors: 358 if err == nil { 359 continue 360 } 361 os.Stderr.WriteString(clear) 362 showError(err) 363 s = startChronoLine(s[:0], start, time.Now()) 364 os.Stderr.Write(s) 365 366 case <-quit: 367 os.Stderr.Write(endChronoLine(s[:0], start)) 368 return justQuit{0} 369 370 case <-stopped: 371 t.Stop() 372 os.Stderr.Write(endChronoLine(s[:0], start)) 373 return justQuit{forceQuitCode} 374 } 375 } 376 } 377 378 func startChronoLine(buf []byte, start, now time.Time) []byte { 379 dt := now.Sub(start) 380 buf = time.Time{}.Add(dt).AppendFormat(buf, chronoFormat) 381 buf = append(buf, gap...) 382 buf = now.AppendFormat(buf, dateTimeFormat) 383 return buf 384 } 385 386 func endChronoLine(buf []byte, start time.Time) []byte { 387 secs := time.Since(start).Seconds() 388 buf = append(buf, gap...) 389 buf = strconv.AppendFloat(buf, secs, 'f', 4, 64) 390 buf = append(buf, " seconds\n"...) 391 return buf 392 }