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:
parent
3afeb0f8d4
commit
efba13d4df
|
|
@ -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) {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user