From 6ea25388d7cd527b01164bb776a287d5d1d5b369 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 16 Jun 2026 17:37:12 +0200 Subject: [PATCH] controller v0.72.0: FileBrowser converges on boot-recreate processGuestBootChange recreated the drive-backed app stacks but never re-synced FileBrowser (base-infra, no HDD_PATH), so its drive mounts went stale after a reboot. Now, AFTER pollLiveBinds confirms the live binds and the apps are recreated, trigger go s.SyncFileBrowserMounts() so FileBrowser converges against the now-live drives. Refactored into pure recreateDriveBackedApps(stacks, present, recreate, syncFB). Tests: FB sync runs once after recreate (red-proofed companion); runs even when nothing recreated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++ controller/internal/web/intermediary.go | 53 ++++++++++++++++---- controller/internal/web/intermediary_test.go | 37 ++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa4d2b..3012d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## Changelog +### v0.72.0 — FileBrowser converges on boot-recreate (2026-06-16) + +Follow-up to v0.71.0: a host-reboot test found `processGuestBootChange` recreated the drive-backed app +stacks but **never re-synced FileBrowser**, so its drive mounts went stale after a reboot (FileBrowser +binds each drive's `userdata` but is base-infra with no `HDD_PATH`, so it is not in the recreate set). +Now, **after** `pollLiveBinds` confirms the live binds and the apps are recreated, the boot-recreate path +triggers `go s.SyncFileBrowserMounts()` so FileBrowser converges against the now-live drives (the sync +runs unconditionally so FileBrowser reflects the current bind state even if no app needed recreating). +Refactored the recreate loop into a pure, testable `recreateDriveBackedApps(stacks, present, recreate, +syncFB)`. Tests: FileBrowser sync invoked once, AFTER every recreate (red-proofed companion: pre-fix path +never synced); and synced even when nothing was recreated. Pairs with felhom-agent's host-reboot +remount-by-UUID fix; live-accepted with a real `felhom-pve` reboot. + ### v0.71.0 — fix guest-reboot recovery of drive-backed apps (boot-race + the agent-path blocker) (2026-06-16) A `pct reboot` of the guest left drive-backed apps (audiobookshelf, calibre-web, immich-server, diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index 0cfabfd..87d42d6 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -370,24 +370,26 @@ func (s *Server) processGuestBootChange() { s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", resp.GuestBootID, bootBindWait, paths) presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll) - skipped := 0 + var bootStacks []bootStack for _, st := range s.stackMgr.GetStacks() { cfg := s.stackMgr.LoadAppConfigByName(st.Name) if cfg == nil { continue } - if !shouldRecreateOnBoot(cfg.Deployed, cfg.Env["HDD_PATH"], presentStable) { - if cfg.Deployed && strings.HasPrefix(cfg.Env["HDD_PATH"], StableParentDir+"/") { - skipped++ // a deployed drive-backed app whose bind never went live → gate's job - } - continue - } - s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, st.Name, st.State, cfg.Env["HDD_PATH"]) - _ = s.stackMgr.StopStack(st.Name) - if serr := s.stackMgr.StartStack(st.Name); serr != nil { - s.logger.Printf("[WARN] [gate] boot recreate %s: %v", st.Name, serr) + bootStacks = append(bootStacks, bootStack{name: st.Name, deployed: cfg.Deployed, hdd: cfg.Env["HDD_PATH"], state: string(st.State)}) + } + recreate := func(bs bootStack) { + s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd) + _ = s.stackMgr.StopStack(bs.name) + if serr := s.stackMgr.StartStack(bs.name); serr != nil { + s.logger.Printf("[WARN] [gate] boot recreate %s: %v", bs.name, serr) } } + syncFB := func() { + s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", resp.GuestBootID) + go s.SyncFileBrowserMounts() + } + _, skipped := recreateDriveBackedApps(bootStacks, presentStable, recreate, syncFB) if skipped > 0 { s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", resp.GuestBootID, skipped, bootBindWait) } @@ -396,6 +398,35 @@ func (s *Server) processGuestBootChange() { } } +// bootStack is one deployed stack's boot-recreate inputs (decoupled from stacks.Manager for testing). +type bootStack struct { + name string + deployed bool + hdd string + state string +} + +// recreateDriveBackedApps recreates every deployed drive-backed app whose drive bind is live, then +// triggers the FileBrowser sync. FileBrowser binds the drives' userdata but is base-infra (no HDD_PATH), +// so it is NOT in the recreate set — it must be converged HERE, AFTER the recreate (which itself only +// ran once pollLiveBinds confirmed the live binds), so FileBrowser's mounts reflect the now-live drives +// instead of going stale (the gap a host/guest reboot left before this fix). syncFB is always called so +// FileBrowser reflects the current bind state even if no app needed recreating. Pure (ops injected). +func recreateDriveBackedApps(stacks []bootStack, presentStable map[string]bool, recreate func(bootStack), syncFB func()) (recreated, skipped int) { + for _, bs := range stacks { + if !shouldRecreateOnBoot(bs.deployed, bs.hdd, presentStable) { + if bs.deployed && strings.HasPrefix(bs.hdd, StableParentDir+"/") { + skipped++ // a deployed drive-backed app whose bind never went live → gate's job + } + continue + } + recreate(bs) + recreated++ + } + syncFB() + return +} + // ---- H1 endpoints (the UI's settings.js calls these; previously 404/unrouted) ----------------- // handleStorageDisconnect EJECTS a drive without restart: stop its apps (gate-stopped), agent-detach the diff --git a/controller/internal/web/intermediary_test.go b/controller/internal/web/intermediary_test.go index 83d1dd8..efe43bd 100644 --- a/controller/internal/web/intermediary_test.go +++ b/controller/internal/web/intermediary_test.go @@ -139,6 +139,43 @@ func TestPollLiveBinds_TimeoutLeavesAbsent(t *testing.T) { } } +// TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate (Task B): FileBrowser must be re-synced +// AFTER the drive-backed apps are recreated (so it syncs against live binds). COMPANION: the pre-fix +// boot-recreate path never synced FileBrowser — red-proofed by dropping the syncFB() call. +func TestRecreateDriveBackedApps_SyncsFileBrowserAfterRecreate(t *testing.T) { + flash := "/mnt/felhom-drives/felhom-flash" + present := map[string]bool{flash: true} + stacks := []bootStack{ + {name: "romm", deployed: true, hdd: flash}, // drive-backed, live → recreate + {name: "actualbudget", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}, // SSD → not recreated + {name: "stranded", deployed: true, hdd: "/mnt/felhom-drives/felhom-usb"}, // drive-backed, bind NOT live → skipped + } + var seq []string + recreate := func(bs bootStack) { seq = append(seq, "recreate:"+bs.name) } + syncFB := func() { seq = append(seq, "syncFB") } + + recreated, skipped := recreateDriveBackedApps(stacks, present, recreate, syncFB) + + if recreated != 1 || skipped != 1 { + t.Fatalf("recreated=%d skipped=%d, want 1/1", recreated, skipped) + } + // FileBrowser sync MUST be invoked, and AFTER every recreate. + if len(seq) != 2 || seq[0] != "recreate:romm" || seq[len(seq)-1] != "syncFB" { + t.Fatalf("FileBrowser sync must run once, AFTER the recreate; seq=%v", seq) + } +} + +// TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate: FileBrowser is re-synced to reflect the live +// binds even when no app needed recreating (so its mounts never go stale). +func TestRecreateDriveBackedApps_SyncsEvenWithNoRecreate(t *testing.T) { + stacks := []bootStack{{name: "x", deployed: true, hdd: "/mnt/sys_drive/felhom-data"}} // SSD only + synced := false + recreateDriveBackedApps(stacks, map[string]bool{}, func(bootStack) { t.Fatal("should not recreate") }, func() { synced = true }) + if !synced { + t.Fatal("FileBrowser sync must run even when nothing was recreated") + } +} + func TestStablePathForName(t *testing.T) { if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" { t.Errorf("stablePathForName = %q", got)