// Package logx is the shared sink for launcher logs: every line goes to // stderr and, once configured, to a file — the Windows GUI build has no // console, so without the file sink most diagnostics would vanish. package logx import ( "bytes" "fmt" "io" "os" "path/filepath" "runtime" ) var writers = []io.Writer{os.Stderr} // AddSink appends a writer that receives all subsequent lines. func AddSink(w io.Writer) { writers = append(writers, w) } // Capped returns an io.Writer over f that keeps the file under max bytes: // when a write would cross max, the oldest bytes are dropped first so only // the newest max/2 remain. The starting size comes from f.Stat; if that or a // later trim fails (e.g. another program holds the file open), trimming is // skipped and the append proceeds — the cap is best-effort. f must be opened // for both reading and writing (os.O_RDWR). func Capped(f *os.File, max int64) io.Writer { size := int64(0) if fi, err := f.Stat(); err == nil { size = fi.Size() } return &capped{f: f, size: size, max: max} } type capped struct { f *os.File size int64 max int64 } func (c *capped) Write(p []byte) (int, error) { if c.size+int64(len(p)) > c.max { c.trim() } n, err := c.f.Write(p) c.size += int64(n) return n, err } // trim rewrites f to contain only its newest max/2 bytes (never starting on a // partial line). Any failure leaves the file untouched. func (c *capped) trim() { keep := c.max / 2 if _, err := c.f.Seek(-keep, io.SeekEnd); err != nil { return } tail, err := io.ReadAll(io.LimitReader(c.f, keep)) if err != nil { return } if i := bytes.IndexByte(tail, '\n'); i >= 0 { tail = tail[i+1:] // drop the partial line at the front } if err := c.f.Truncate(0); err != nil { return } if _, err := c.f.WriteAt(tail, 0); err != nil { return } // WriteAt leaves the file offset untouched; park it at the new end so a // following plain append lands right after tail. if _, err := c.f.Seek(int64(len(tail)), io.SeekStart); err != nil { return } c.size = int64(len(tail)) } // F writes one prefixed log line to every sink. If the format string's verb // count does not match its argument count — which Sprintf would render as // "%!v(MISSING)" or silently drop — an extra diagnostic line naming the exact // call site is written first, because go vet does not check user-defined // printf wrappers like this one and a bare marker in launcher.log gives no // way back to its source. func F(prefix string, format string, args ...any) { if n := countVerbs(format); n != len(args) { _, file, line, ok := runtime.Caller(1) where := "unknown" if ok { where = fmt.Sprintf("%s:%d", filepath.Base(file), line) } diag := fmt.Sprintf("%s LOG-FORMAT-MISMATCH at %s: format=%q has %d verb(s), got %d arg(s)\n", prefix, where, format, n, len(args)) for _, w := range writers { fmt.Fprint(w, diag) } } // Sprintf has a single fixed (format) parameter, so prefix must travel // inside the value slice rather than as an extra explicit argument. all := make([]any, 0, len(args)+1) all = append(all, prefix) all = append(all, args...) line := fmt.Sprintf("%s "+format+"\n", all...) for _, w := range writers { fmt.Fprint(w, line) } } // countVerbs counts the non-literal verbs in a printf-style format string: // each % starts a verb unless it is escaped (%%); flags, width and precision // may sit between the % and the single-char verb. A bare trailing % is not a // verb (Go renders it as a literal). func countVerbs(format string) int { n := 0 for i := 0; i < len(format); i++ { if format[i] != '%' { continue } i++ // skip the % if i >= len(format) || format[i] == '%' { continue // bare or escaped percent: no verb } for i < len(format) && isVerbModifier(format[i]) { i++ } if i >= len(format) { break // % with only modifiers and no verb char } n++ // format[i] is the verb; the outer loop consumes it } return n } func isVerbModifier(c byte) bool { switch c { case '-', '+', '#', '0', '.', '*', ' ': return true } return c >= '0' && c <= '9' }