Bound the startup browser-dir probe and log startup phases

The relaunch restore probes the saved browser dir with os.ReadDir on
the UI thread before the first frame. A FUSE stall on external storage
there would hang the app on the splash screen (reported on device,
intermittent; the 17:15 occurrence rotated out of logcat before it
could be pulled and left no pad.pad ANR). Two changes:

- dirReadable: run the probe in a helper goroutine with a 2 s deadline;
  a stalled read now costs at most 2 s and falls back to the startup
  directory instead of hanging the splash.
- Phase logs (session load duration, restore, entering frame loop,
  first frame rendered) so the next hang bisects itself: the missing
  line names the blocked phase.

Verified on device: full chain logs within 40 ms of process start.
This commit is contained in:
Greg Pomerantz 2026-10-08 22:09:08 -04:00
parent 6bd5588ade
commit 3afeb0f8d4

View File

@ -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