diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 6e01531..4d90da4 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -117,7 +117,10 @@ func run(w *app.Window) error { // the browser at the saved directory. sessPath := sessionFilePath() logic.SetSessionSaver(newSessionSaver(sessPath)) - if sess, ok := loadSession(sessPath); ok { + tSess := time.Now() + sess, ok := loadSession(sessPath) + log.Printf("startup: session load %s (%v)", map[bool]string{true: "restorable", false: "none"}[ok], time.Since(tSess)) + if ok { if sess.InBrowser { log.Printf("restoring session: browser path=%q", sess.BrowserPath) // The saved directory may have vanished (deleted, synced away) or @@ -126,8 +129,11 @@ func run(w *app.Window) error { // media_rw:media_rw 0750; only /storage/emulated/0 is app-exposed). // Probe with a real directory read — the same operation the // browser's index does — rather than os.Stat, which passes for - // unreadable dirs. Fall back to the startup directory otherwise. - if _, err := os.ReadDir(sess.BrowserPath); err == nil { + // unreadable dirs. The probe runs BOUNDED: it happens on the UI + // thread before the first frame, and a FUSE stall on external + // storage must not turn into a splash-screen hang — if the read + // is not done in time, start in the startup directory instead. + if dirReadable(sess.BrowserPath) { logic.BeginBrowserRestore(sess.BrowserPath) } } else { @@ -158,6 +164,10 @@ func run(w *app.Window) error { go frameReceiver(w, &mu, &frame, logic.FrameChan()) go logic.Run() + log.Printf("startup: logic running, entering frame loop") + // 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. // lastExcl tracks the selection-handle grab boxes last sent to // SetGestureExclusions (Android: keeps the system back gesture from @@ -176,6 +186,7 @@ func run(w *app.Window) error { // lastFrameW/H hold the previous FrameEvent's window size (px); 0 = no // frame yet. Used to spot shrink frames (see ZeroWheelScroll below). var lastFrameW, lastFrameH int + var firstFrame bool for { e := w.Event() @@ -193,6 +204,10 @@ func run(w *app.Window) error { PixelHeight: e.Config.Size.Y, } case app.FrameEvent: + if !firstFrame { + firstFrame = true + log.Printf("startup: first frame rendered") + } if perfOn { presentN++ if presentStart.IsZero() { @@ -467,6 +482,29 @@ func run(w *app.Window) error { } } +// dirReadable reports whether path can be listed by this app, with a 2 s +// deadline (see the startup probe in run): it runs os.ReadDir in a helper +// goroutine so a FUSE stall on external storage cannot hold the UI thread +// before the first frame (a splash-screen hang). The helper goroutine leaks +// on timeout — a deliberate, one-per-launch, only-on-stall trade. +func dirReadable(path string) bool { + ch := make(chan error, 1) + go func() { + _, err := os.ReadDir(path) + ch <- err + }() + select { + case err := <-ch: + if err != nil { + log.Printf("startup: browser dir %q not readable: %v (starting in the startup directory)", path, err) + } + return err == nil + case <-time.After(2 * time.Second): + log.Printf("startup: browser dir probe for %q timed out (starting in the startup directory)", path) + return false + } +} + // exclEqual reports whether two exclusion-rect sets are identical. // newSessionSaver builds the relaunch-session file writer registered with // the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn