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 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
|
|
||||||
// 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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user