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