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