Restore the browser directory in the background; drop the startup probe

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.
This commit is contained in:
Greg Pomerantz 2026-10-08 22:59:51 -04:00
parent 3afeb0f8d4
commit efba13d4df
5 changed files with 124 additions and 36 deletions

View File

@ -123,19 +123,13 @@ func run(w *app.Window) error {
if ok { if ok {
if sess.InBrowser { if sess.InBrowser {
log.Printf("restoring session: browser path=%q", sess.BrowserPath) log.Printf("restoring session: browser path=%q", sess.BrowserPath)
// The saved directory may have vanished (deleted, synced away) or // No readability check here: the background index load Run
// be unreadable by this app (stat succeeds but the permission bits // dispatches is the probe, and its failure path (the manager's
// deny the read — e.g. /storage/emulated itself, which is // initial-load fallback) reverts to the startup directory if the
// media_rw:media_rw 0750; only /storage/emulated/0 is app-exposed). // saved one is gone or unreadable. Keeping every filesystem
// Probe with a real directory read — the same operation the // operation off the pre-first-frame path is a hard requirement —
// browser's index does — rather than os.Stat, which passes for // a FUSE stall there is a splash-screen hang.
// unreadable dirs. The probe runs BOUNDED: it happens on the UI logic.BeginBrowserRestore(sess.BrowserPath)
// 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 { } else {
log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery) log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery)
logic.BeginRestore(sess) 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. // exclEqual reports whether two exclusion-rect sets are identical.
// newSessionSaver builds the relaunch-session file writer registered with // newSessionSaver builds the relaunch-session file writer registered with
// the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn // the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn

View File

@ -29,6 +29,25 @@ type BrowserManager struct {
// idles. Owner-goroutine-only. // idles. Owner-goroutine-only.
lastIndexPath string lastIndexPath string
lastIndexSignature uint64 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) { 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 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 bm.state.Loading = false
// In the mock implementation, BuildIndexTask returns []types.DirEntry. // In the mock implementation, BuildIndexTask returns []types.DirEntry.
@ -270,6 +294,22 @@ func (bm *BrowserManager) handleError(result pool.Result) bool {
return false 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 // A failed NAVIGATION: restore the previous path so the user is sent back
// to where they were. // to where they were.
if result.TaskType == pool.TypeBuildIndex && len(bm.state.History) > 0 { if result.TaskType == pool.TypeBuildIndex && len(bm.state.History) > 0 {

View File

@ -551,3 +551,68 @@ func TestHandleResult_NavigationFailureRecoveryReloads(t *testing.T) {
t.Fatal("page 0 not restored after recovery re-read") 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)
}
}

View File

@ -154,6 +154,11 @@ type Logic struct {
fontPinDeadline time.Time fontPinDeadline time.Time
scaleSeen bool scaleSeen bool
restoreContentLanded 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) sessionSaver func(SessionState)
lastSession SessionState lastSession SessionState
lastSessionSave time.Time lastSessionSave time.Time
@ -188,6 +193,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
TheLogic = &Logic{ TheLogic = &Logic{
state: state, state: state,
browserManager: bm, browserManager: bm,
startPath: path,
configChan: make(chan ConfigUpdate), configChan: make(chan ConfigUpdate),
frameChan: make(chan Frame, 1), frameChan: make(chan Frame, 1),
inputChan: make(chan []ui.InputEvent), inputChan: make(chan []ui.InputEvent),

View File

@ -284,9 +284,15 @@ func (l *Logic) BeginRestore(s SessionState) {
// CurrentPath, so it must be called after NewLogic and before Run (the cmd // CurrentPath, so it must be called after NewLogic and before Run (the cmd
// layer does both before starting the logic goroutine). An empty dirPath // layer does both before starting the logic goroutine). An empty dirPath
// keeps the startup directory. // 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) { func (l *Logic) BeginBrowserRestore(dirPath string) {
if dirPath != "" { if dirPath != "" {
l.state.Browser.CurrentPath = dirPath l.state.Browser.CurrentPath = dirPath
l.browserManager.SetLoadFallback(l.startPath)
} }
} }