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