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 58 const ( 59 // every = 100 * time.Millisecond 60 // chronoFormat = `15:04:05.0` 61 62 // every = 500 * time.Millisecond 63 // chronoFormat = `15:04:05` 64 65 every = 1000 * time.Millisecond 66 chronoFormat = `15:04:05` 67 ) 68 69 const info = ` 70 timer [command...] [args...] 71 72 73 Run a live timer/chronograph on stderr, always showing below all lines 74 from stdin, which update stdout as they come. 75 76 When stdin is over, it simply quits showing how long it ran, by extending 77 its last visible timer line on stderr. 78 79 You can also use this app without piping anything to its stdin, acting as 80 a simple live clock until you force-quit it. 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 stderr lines show without interference. 85 ` 86 87 func main() { 88 args := os.Args[1:] 89 if len(args) > 0 { 90 switch args[0] { 91 case `-h`, `--h`, `-help`, `--help`: 92 os.Stderr.WriteString(info[1:]) 93 return 94 95 case `--`: 96 args = args[1:] 97 } 98 } 99 100 if len(args) > 0 { 101 os.Exit(runTask(args[0], args[1:])) 102 } 103 104 err := chronograph(os.Stdin, nil) 105 if quit, ok := err.(justQuit); ok { 106 os.Exit(quit.exitCode) 107 } 108 } 109 110 // justQuit is a custom error-type which isn't for showing, but for quitting 111 // the app right away instead 112 type justQuit struct { 113 exitCode int 114 } 115 116 // Error is only to satisfy the error interface, and not for showing 117 func (justQuit) Error() string { 118 return `quitting right away` 119 } 120 121 // runTask handles running the app in `subtask-mode` 122 func runTask(name string, args []string) (exitCode int) { 123 cmd := exec.Command(name, args...) 124 cmd.Stdin = os.Stdin 125 126 stdout, err := cmd.StdoutPipe() 127 if err != nil { 128 showError(err) 129 return 1 130 } 131 defer stdout.Close() 132 133 stderr, err := cmd.StderrPipe() 134 if err != nil { 135 showError(err) 136 return 1 137 } 138 defer stderr.Close() 139 140 if err := cmd.Start(); err != nil { 141 showError(err) 142 return 1 143 } 144 145 err = chronograph(stdout, stderr) 146 if quit, ok := err.(justQuit); ok { 147 return quit.exitCode 148 } 149 if err != nil { 150 showError(err) 151 } 152 153 if err := cmd.Wait(); err != nil { 154 showError(err) 155 } 156 return cmd.ProcessState.ExitCode() 157 } 158 159 // showError gives a consistent style/look to any of the app's own errors 160 // func showError(err error) { 161 // if err == nil { 162 // return 163 // } 164 // os.Stderr.WriteString("\x1b[31m") 165 // os.Stderr.WriteString(err.Error()) 166 // os.Stderr.WriteString("\x1b[0m\n") 167 // } 168 169 // showError gives a consistent style/look to any of the app's own errors 170 func showError(err error) { 171 if err == nil { 172 return 173 } 174 175 os.Stderr.WriteString(err.Error()) 176 os.Stderr.WriteString("\n") 177 } 178 179 // readLines is run twice asynchronously, so that both stdin and stderr lines 180 // are handled independently, which matters when running a subtask 181 func readLines(r io.Reader, lines chan []byte) error { 182 defer close(lines) 183 184 // when not handling a subtask, this func will be called with a nil 185 // reader, since without a subtask, there's no stderr to read from 186 if r == nil { 187 return nil 188 } 189 190 const gb = 1024 * 1024 * 1024 191 sc := bufio.NewScanner(r) 192 sc.Buffer(nil, 8*gb) 193 194 for sc.Scan() { 195 lines <- sc.Bytes() 196 } 197 return sc.Err() 198 } 199 200 // chronograph runs a live chronograph, showing the time elapsed: 2 input 201 // sources for lines are handled concurrently, one destined for the app's 202 // stdout, the other for the app's stderr, without interfering with the 203 // chronograph lines, which also show on stderr 204 func chronograph(stdout io.Reader, stderr io.Reader) error { 205 start := time.Now() 206 t := time.NewTicker(every) 207 startChronoLine(start, start) 208 209 stopped := make(chan os.Signal, 1) 210 defer close(stopped) 211 signal.Notify(stopped, os.Interrupt) 212 213 errors := make(chan error) 214 var waitAllLines sync.WaitGroup 215 waitAllLines.Add(2) 216 217 outLines := make(chan []byte) 218 go func() { 219 defer waitAllLines.Done() 220 if stdout != nil { 221 errors <- readLines(stdout, outLines) 222 } 223 }() 224 225 errLines := make(chan []byte) 226 go func() { 227 defer waitAllLines.Done() 228 if stderr != nil { 229 errors <- readLines(stderr, errLines) 230 } 231 }() 232 233 quit := make(chan struct{}) 234 defer close(quit) 235 236 go func() { 237 waitAllLines.Wait() 238 close(errors) 239 quit <- struct{}{} 240 }() 241 242 s := make([]byte, 0, 1024) 243 244 for { 245 select { 246 case now := <-t.C: 247 os.Stderr.WriteString(clear) 248 startChronoLine(start, now) 249 250 case line := <-outLines: 251 if line == nil { 252 // filter out junk for needlessly-chatty pipes, while still 253 // keeping actual empty lines 254 continue 255 } 256 257 // write-order of the next 3 steps matters, to avoid mixing 258 // up lines since stdout and stderr lines can show up together 259 s = append(append(append(s[:0], clear...), line...), '\n') 260 _, err := os.Stdout.Write(s) 261 startChronoLine(start, time.Now()) 262 if err != nil { 263 endChronoLine(start) 264 return nil 265 } 266 267 case line := <-errLines: 268 if line == nil { 269 // filter out junk for needlessly-chatty pipes, while still 270 // keeping actual empty lines 271 continue 272 } 273 274 // write-order of the next 3 steps matters, to avoid mixing 275 // up lines since stdout and stderr lines can show up together 276 os.Stderr.WriteString(clear) 277 s = append(append(s[:0], line...), '\n') 278 _, err := os.Stdout.Write(s) 279 startChronoLine(start, time.Now()) 280 if err != nil { 281 endChronoLine(start) 282 return err 283 } 284 285 case err := <-errors: 286 if err == nil { 287 continue 288 } 289 os.Stderr.WriteString(clear) 290 showError(err) 291 startChronoLine(start, time.Now()) 292 293 case <-quit: 294 endChronoLine(start) 295 return justQuit{0} 296 297 case <-stopped: 298 t.Stop() 299 endChronoLine(start) 300 return justQuit{255} 301 } 302 } 303 } 304 305 func startChronoLine(start, now time.Time) { 306 var buf [64]byte 307 dt := now.Sub(start) 308 309 s := buf[:0] 310 s = time.Time{}.Add(dt).AppendFormat(s, chronoFormat) 311 s = append(s, gap...) 312 s = now.AppendFormat(s, dateTimeFormat) 313 os.Stderr.Write(s) 314 } 315 316 func endChronoLine(start time.Time) { 317 var buf [64]byte 318 secs := time.Since(start).Seconds() 319 320 s := buf[:0] 321 s = append(s, gap...) 322 s = strconv.AppendFloat(s, secs, 'f', 4, 64) 323 s = append(s, " seconds\n"...) 324 os.Stderr.Write(s) 325 }