Add a 15s startup watchdog that dumps all goroutine stacks

The splash hang is intermittent and the process is unattachable from the
shell (not debuggable, no root, no signal permission), so the next
occurrence must be self-reporting: if no frame renders within 15 s of
entering the frame loop, log debug.Stack() — the blocked goroutine's
stack is the diagnosis.
This commit is contained in:
Greg Pomerantz 2026-10-10 06:49:11 -04:00
parent efba13d4df
commit c6fb426282

View File

@ -7,6 +7,7 @@ import (
"log"
"os"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"time"
@ -156,9 +157,24 @@ func run(w *app.Window) error {
var mu sync.Mutex
var frame editor.Frame
firstFrameCh := make(chan struct{}) // closed when the first frame renders (watchdog)
go frameReceiver(w, &mu, &frame, logic.FrameChan())
go logic.Run()
log.Printf("startup: logic running, entering frame loop")
// Startup watchdog: if no frame has been rendered 15 s after entering
// the frame loop, dump every goroutine's stack. A splash-screen hang is
// intermittent and usually gone by the time anyone can attach a
// debugger; the dump makes the next occurrence self-reporting — which
// goroutine is blocked where (FUSE read, channel, GL loop) is the whole
// diagnosis.
go func() {
select {
case <-firstFrameCh:
case <-time.After(15 * time.Second):
log.Printf("STARTUP WATCHDOG: no frame 15s after entering the frame loop — goroutine dump:\n%s", debug.Stack())
}
}()
// firstFrame logs the moment the UI actually drew something (splash
// done): the anchor for bisecting startup hangs — the log line missing
// after "entering frame loop" points at the blocked phase.
@ -200,6 +216,7 @@ func run(w *app.Window) error {
case app.FrameEvent:
if !firstFrame {
firstFrame = true
close(firstFrameCh)
log.Printf("startup: first frame rendered")
}
if perfOn {