From efba13d4df63de0779417fc28b9a407c2a620084 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 8 Oct 2026 22:59:51 -0400 Subject: [PATCH] Restore the browser directory in the background; drop the startup probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relaunch restore checked the saved browser directory with os.ReadDir on the UI thread before the first frame (bounded to 2 s in the previous commit). The check was unnecessary: Run dispatches a background index load of Browser.CurrentPath immediately, and that load IS the readability probe. So: - main.go no longer does any pre-first-frame directory read; BeginBrowserRestore is called unconditionally for browser sessions. - BrowserManager gains a one-shot initial-load fallback (SetLoadFallback): when the startup load fails (dir deleted by a sync, or unreadable by the app — /storage/emulated is 0750 media_rw), the manager reverts to the armed fallback (the startup directory) and loads it, instead of resetting to a blank browser. Any successful index disarms it, so later failures keep the History-recovery path. - Logic stores its start path and arms the fallback from BeginBrowserRestore. Verified on device with seeded sessions pointing at a deleted dir and at unreadable /storage/emulated: both fall back to the startup dir in ~20 ms, first frame renders, list populated. New manager test TestHandleResult_InitialLoadFailureFallsBack. --- cmd/pad/main.go | 43 ++++----------------- internal/browser/manager.go | 40 ++++++++++++++++++++ internal/browser/manager_test.go | 65 ++++++++++++++++++++++++++++++++ internal/editor/logic.go | 6 +++ internal/editor/session.go | 6 +++ 5 files changed, 124 insertions(+), 36 deletions(-) diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 4d90da4..dc4b43d 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -123,19 +123,13 @@ func run(w *app.Window) error { if ok { if sess.InBrowser { log.Printf("restoring session: browser path=%q", sess.BrowserPath) - // The saved directory may have vanished (deleted, synced away) or - // be unreadable by this app (stat succeeds but the permission bits - // deny the read — e.g. /storage/emulated itself, which is - // 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. 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) - } + // No readability check here: the background index load Run + // dispatches is the probe, and its failure path (the manager's + // initial-load fallback) reverts to the startup directory if the + // saved one is gone or unreadable. Keeping every filesystem + // operation off the pre-first-frame path is a hard requirement — + // a FUSE stall there is a splash-screen hang. + logic.BeginBrowserRestore(sess.BrowserPath) } else { log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery) logic.BeginRestore(sess) @@ -482,29 +476,6 @@ 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 diff --git a/internal/browser/manager.go b/internal/browser/manager.go index 421c3b0..d666aab 100644 --- a/internal/browser/manager.go +++ b/internal/browser/manager.go @@ -29,6 +29,25 @@ type BrowserManager struct { // idles. Owner-goroutine-only. lastIndexPath string lastIndexSignature uint64 + // loadFallbackPath is the directory a FAILED INITIAL Load reverts to + // (a relaunch-restore candidate that turned out to be gone or unreadable + // — deleted by a sync, or /storage/emulated, which apps cannot read): + // the browser loads it in the background instead of resetting to a blank + // view. One-shot per startup, disarmed by any successful index (the + // restore question is settled once a directory has loaded). Empty = no + // fallback. Owner-goroutine-only. + loadFallbackPath string +} + +// SetLoadFallback arranges for a failed initial Load to revert to dirPath +// and load it, instead of resetting the browser to a blank view. The cmd +// layer arms it when a relaunch session restores a browser directory that +// has not been checked yet: the background load itself is the readability +// probe (no blocking FS op on the startup path). Must be called before Run. +func (bm *BrowserManager) SetLoadFallback(dirPath string) { + if dirPath != "" { + bm.loadFallbackPath = dirPath + } } func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs pool.FileSystem) (*BrowserManager, error) { @@ -153,6 +172,11 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) bool { return false } + // Any successful index settles the initial-load question: the restore + // fallback is no longer needed (a later failure has History to revert + // to instead). + bm.loadFallbackPath = "" + bm.state.Loading = false // In the mock implementation, BuildIndexTask returns []types.DirEntry. @@ -270,6 +294,22 @@ func (bm *BrowserManager) handleError(result pool.Result) bool { return false } + // A failed INITIAL LOAD with a fallback armed (the relaunch-restore + // candidate is gone or unreadable): revert to the fallback directory in + // the background, one-shot. This is the async counterpart of the old + // synchronous pre-startup directory probe — nothing on the startup path + // blocks on a read of the restore candidate. + if result.TaskType == pool.TypeBuildIndex && bm.loadFallbackPath != "" { + fallback := bm.loadFallbackPath + bm.loadFallbackPath = "" + fmt.Printf("handleError: cannot load %q; falling back to %q\n", result.DirPath, fallback) + bm.state.History = bm.state.History[:0] + bm.state.CurrentPath = fallback + navigateToDirectory(bm.state, fallback) + bm.dispatchIndex(fallback, true) + return true + } + // A failed NAVIGATION: restore the previous path so the user is sent back // to where they were. if result.TaskType == pool.TypeBuildIndex && len(bm.state.History) > 0 { diff --git a/internal/browser/manager_test.go b/internal/browser/manager_test.go index 7c0c372..1bdc272 100644 --- a/internal/browser/manager_test.go +++ b/internal/browser/manager_test.go @@ -551,3 +551,68 @@ func TestHandleResult_NavigationFailureRecoveryReloads(t *testing.T) { t.Fatal("page 0 not restored after recovery re-read") } } + +// TestHandleResult_InitialLoadFailureFallsBack covers the relaunch-restore +// path: a session points at a browser directory that is gone or unreadable. +// The startup does NOT check it synchronously (a blocking read there is a +// splash-screen hang risk); the background initial Load fails instead, and +// the manager must revert to the armed fallback directory — not reset to a +// blank browser. +func TestHandleResult_InitialLoadFailureFallsBack(t *testing.T) { + state := NewBrowserState() + fs := mock.NewFileSystem() + wp := pool.NewWorkerPool(1) + wp.Start() + defer wp.Stop() + + bm, err := NewBrowserManager(state, wp, fs) + if err != nil { + t.Fatalf("NewBrowserManager failed: %v", err) + } + + // Startup with a session-restored candidate and the startup dir armed + // as the fallback (as Logic.BeginBrowserRestore does). + state.CurrentPath = "/gone" + bm.SetLoadFallback("/startup") + bm.indexInFlightPath = "/gone" + bm.indexInFlightIsNav = true + + // The initial load fails: fall back to /startup, one-shot. + bm.HandleResult(pool.Result{TaskType: pool.TypeBuildIndex, Success: false, Error: ErrDirNotFound, DirPath: "/gone"}) + if state.CurrentPath != "/startup" { + t.Fatalf("CurrentPath = %q, want /startup (fallback)", state.CurrentPath) + } + if bm.loadFallbackPath != "" { + t.Error("fallback not consumed after the first failure") + } + + // The fallback load succeeds: a normal view (1 entry + synthetic ".."). + entries := []types.DirEntry{testDirEntry{name: "a.txt", size: 1, mod: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)}} + bm.indexInFlightPath = "/startup" + bm.indexInFlightIsNav = true + bm.HandleResult(pool.Result{TaskType: pool.TypeBuildIndex, Success: true, DirPath: "/startup", Data: entries}) + if state.CurrentPath != "/startup" || state.TotalEntries != 2 { + t.Fatalf("after fallback load: path=%q TotalEntries=%d, want /startup with 2", state.CurrentPath, state.TotalEntries) + } + if bm.loadFallbackPath != "" { + t.Error("fallback still armed after a successful index") + } + + // The disarm matters: a LATER (navigation) failure must use History + // recovery, not the startup fallback. + state.History = append(state.History, "/startup") + state.CurrentPath = "/other" + bm.indexInFlightPath = "/other" + bm.indexInFlightIsNav = true + bm.SetLoadFallback("/startup") // re-arm to prove the next success clears it + bm.handleBuildIndexSuccess(pool.Result{TaskType: pool.TypeBuildIndex, Success: true, DirPath: "/other", Data: entries}) + if bm.loadFallbackPath != "" { + t.Fatal("fallback still armed after a successful index") + } + bm.indexInFlightPath = "/other" + bm.indexInFlightIsNav = true + bm.HandleResult(pool.Result{TaskType: pool.TypeBuildIndex, Success: false, Error: ErrDirNotFound, DirPath: "/other"}) + if state.CurrentPath != "/startup" { + t.Fatalf("after nav failure CurrentPath = %q, want history-recovered /startup", state.CurrentPath) + } +} diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 70efed4..4a11e1d 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -154,6 +154,11 @@ type Logic struct { fontPinDeadline time.Time scaleSeen bool restoreContentLanded bool + // startPath is the browser directory the app starts in when no restore + // applies (NewLogic's path); it is the load fallback for a browser + // restore candidate that turns out to be gone/unreadable (see + // BrowserManager.SetLoadFallback). + startPath string sessionSaver func(SessionState) lastSession SessionState lastSessionSave time.Time @@ -188,6 +193,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic { TheLogic = &Logic{ state: state, browserManager: bm, + startPath: path, configChan: make(chan ConfigUpdate), frameChan: make(chan Frame, 1), inputChan: make(chan []ui.InputEvent), diff --git a/internal/editor/session.go b/internal/editor/session.go index 9009431..fa90d4e 100644 --- a/internal/editor/session.go +++ b/internal/editor/session.go @@ -284,9 +284,15 @@ func (l *Logic) BeginRestore(s SessionState) { // CurrentPath, so it must be called after NewLogic and before Run (the cmd // layer does both before starting the logic goroutine). An empty dirPath // keeps the startup directory. +// +// dirPath is NOT checked here: the background index load Run dispatches is +// the readability probe. If it fails (dir deleted by a sync, unreadable by +// the app), the manager's initial-load fallback reverts to the startup +// directory asynchronously — nothing on the startup path blocks on a read. func (l *Logic) BeginBrowserRestore(dirPath string) { if dirPath != "" { l.state.Browser.CurrentPath = dirPath + l.browserManager.SetLoadFallback(l.startPath) } }