Skip to content

Keep saves on the loaded profile when a profile-switch reload is declined - #578

Merged
mairas merged 2 commits into
mainfrom
fix/profile-repoint-after-reload
Aug 13, 2026
Merged

Keep saves on the loaded profile when a profile-switch reload is declined#578
mairas merged 2 commits into
mainfrom
fix/profile-repoint-after-reload

Conversation

@mairas

@mairas mairas commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Why

setActiveProfile repointed storage.sharedConfigName before calling ReloadService.reload(), which legitimately declines to navigate when the server probe fails or returns 5xx — a Traefik 502 during a Signal K restart, or a timeout over 4s.

The app then kept running with the previous profile's config in memory while every later save PATCHed the newly selected slot, because StorageService.patchConfig builds each path as /${sharedConfigName}/.... Switch profile during a brief SK restart → reload declined → SK returns → user edits a dashboard → the target profile is replaced by the old profile's content.

What

ReloadService.reload() now reports whether it committed the navigation, and setActiveProfile repoints the storage write path only on a commit.

The persisted name is still written before the reload either way, deliberately: the navigation does not return, so it has to be in place beforehand, and leaving it there means a declined switch is deferred rather than lost — the Retry on the "server is still unreachable" toast, or any later reload, boots onto the profile the user picked. Reverting it instead would have made Retry land back on the old profile, and would have pointed the device at a deleted slot on the renameProfile path.

setActiveProfile is async now; its two callers in ProfileService await it, which also keeps the switch inside the mutationInFlight guard for its full duration.

Tests

Two added to settings.service.spec.ts: a declined reload leaves the write path on the loaded profile (fails on main), and it still persists the chosen name so a later boot lands on it. The existing ReloadService fake now models a committed reload rather than resolving undefined.

Full suite: 2031 passing.

Fixes #520

@mairas

mairas commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Code review — correctness + adversarial, findings applied

Two reviewers, independent contexts. Both converged on the same two defects, and the adversarial pass then broke the design decision itself.

Both reviewers found the same two regressions

The change made settings.sharedConfigName and storage.sharedConfigName diverge for the first time. Four call sites answer "which profile is this device on"; the fix moved one of them and left the two destructive ones behind.

resetSettings targeted the pending profile. It writes a blank default over this.sharedConfigName — the newly selected name — while the profile list, on the same screen, marks the old profile "Active on this device" and the button reads "Reset the active profile to defaults ... This replaces the active profile's configuration." So the reset blanked a profile the UI said was not active, irreversibly. Fixed: it now targets the loaded slot.

renameProfile left the write path on a deleted slot. The rename creates the new slot, deletes the old one, then calls setActiveProfile. Holding the write path on the loaded slot — the general rule this PR introduced — points it at the name the rename just removed. Every later save then posts replace /oldName/... against a slot the server no longer has. The author's own justification for not reverting the persisted name ("reverting would point the device at a slot that no longer exists") applies with more force to the branch that was kept. It also hit the untouched ?profile branch, which never reloads at all. Fixed: a rename is content-preserving, so the write path follows the slot to its new name unconditionally.

The adversarial pass broke the remaining design

the repoint on the commit path is only observable when the navigation does not happen

Correct, and decisive. On a reload that lands, the document is replaced and the bootstrap sets storage.sharedConfigName from the persisted name — the assignment has no successor to observe it. Every case where it is observed is a case where the old config is still in memory: a cancelled navigation, the TOCTOU window between a passing probe and the document request, and performReload's __SKIP_TEST__ short-circuit — which perf-harness/lib/skip-config.mjs:162 sets while running against a real server, so a profile switch under the harness reproduced #520 undetectably.

So the gated repoint is gone entirely, and with it the boolean ReloadService.reload() had started returning. The navigation is asynchronous and can still be cancelled; "committed" was never a contract a caller could rely on. What remains is a one-line deletion — never repoint — which is a stronger fix than the one this PR opened with, and setActiveProfile is synchronous again.

The spec that pinned the old behaviour (setActiveProfile keeps StorageService.sharedConfigName coherent) is deleted; both reviewers noted it locked in the harmful branch.

Applied

Finding Reviewers Fix
resetSettings blanks the pending profile, not the one on screen correctness P2, adversarial P1 Targets the loaded slot
Rename + no reload leaves the write path on the deleted slot correctness P2, adversarial P1 Write path follows the rename unconditionally
Gated repoint is dead on success, harmful otherwise, and wrong under the perf-harness adversarial P2 Repoint dropped; reload() returns void again

Three tests added: the write path is never repointed, the reset targets the loaded slot, and the rename moves it (including the ?profile branch that never reloads). Suite: 2034 passing.

Not applied — filed as #582

The deferred switch is invisible: the list marks the old profile active, the Retry toast is displaced by the app's own WebSocket-retry toasts within one backoff interval, the row that would cancel has no Switch button, and deleteProfile permits deleting the pending target. Plus setRemoteContextDemand keying by the pending name, which defeats the AIS fail-open. That is a design question about whether a pending switch should exist at all, not a fix to fold into a data-loss PR.

@mairas
mairas force-pushed the fix/profile-repoint-after-reload branch from a5a880a to 6265848 Compare August 12, 2026 21:41
@mairas

mairas commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Code review — 5 personas, findings applied

Reviewers: correctness, testing, maintainability, project-standards, reliability. Fresh contexts, isolated worktree.

The premise was checked, not assumed

The fix is a deletion, so it is only safe if the bootstrap really does establish the write path. The correctness reviewer traced it end to end: setActiveProfile persists to skip.connectionConfig; on the next document loadLocalStorageConfig() reads it, resolveEffectiveSharedConfigName() returns it absent a ?profile override, and bootstrapRemoteContext assigns StorageService.sharedConfigName at storage.service.ts:587. Profile switching is not broken.

The testing reviewer mutation-checked the rest: reverting settings.service.ts to the merge base turns 2 of the 3 new settings tests red, reverting profile.service.ts turns both new rename tests red, and the full suite stays green — so nothing depended on the deleted test.

Verdict on that deleted test (setActiveProfile keeps StorageService.sharedConfigName coherent): it encoded the bug, not a requirement. Its assertion is precisely the state in which patchConfig writes the old profile's dashboards into the new slot.

Four of five reviewers found the same regression

Not repointing the write path makes "loaded slot" and "persisted name" diverge for the first time, and deleteProfile guarded only the loaded one. After a declined reload the profile the device is about to boot into was an ordinary deletable row — config.component.html:36 disables Delete on profile.isActive, which reads the same loaded-slot comparison. Deleting it left connectionConfig.sharedConfigName on a slot that no longer exists, and the next reload from any cause landed on the degraded missing-shared-config recovery. The old eager repoint had covered that case by accident.

Fixed, with a test using the divergent makeSettingsMock(active, persisted) pair the spec already had but never used for delete. Mutation-checked: disabling the new guard fails it.

Two more consumers read the wrong name

  • setRemoteContextDemand keyed by the persisted name while computing the answer from the loaded profile's dashboards. The next boot reads that key pre-auth to pick subscribe scope and a wrong false does not fail open, so that boot subscribes to no AIS targets — the #386 failure mode. Pre-existing (both names were the pending one before), one line, same class as what this PR fixes, so fixed here. The comment asserting the two cannot diverge is corrected. The existing Subscribe to remote (AIS/DSC) contexts only on widget demand #386 test ran setActiveProfile('night') then setRemoteContextDemand(true) — exactly the divergent sequence — under an unbootstrapped fixture where they cannot diverge, so it passed trivially; a bootstrapped case now covers it.
  • renameProfile moved the write path after the old slot's removal had drained. patchConfig bakes the slot name in at enqueue time, so a save queued in that window targeted a slot the queue then deleted ahead of it — the write fails and the user's last edit is lost from both slots. The move is now before the removal, where the new slot exists and the old one does not yet.

Comments trimmed

setActiveProfile's 11-line block documented why an assignment is absent, which is a legitimate why-comment and stays — cut to the invariant and one case. reload.service.ts opened with "Deliberately reports nothing", which defends a return type that only ever existed inside this branch; recast as the caller contract. Confirmed no caller depended on the richer return.

Not actioned

A deferred switch is still invisible and uncancellable — the profile list flags the old profile active while the device will boot into the new one, and the toast never names the profile. That is #582 and a design question about whether a pending switch should exist at all.

StorageService.sharedConfigName and SettingsService.sharedConfigName now deliberately disagree while sharing an identifier. The reviewer's verdict was that the divergence is right and the shared name is not; renaming touches the bootstrap contract and belongs in its own PR. Two pre-existing durability issues are out of scope and unfixed: listConfigs has no request timeout, so a hung call latches mutationInFlight for the session, and a declined reload after resetSettings lets the next autosave undo the reset.

Gate on the head commit: 2042 tests pass, snc clean.

mairas added 2 commits August 13, 2026 11:03
…ined

Skip reloads to load a profile, and ReloadService refuses that reload when the
server does not answer — during a Signal K restart, for example. setActiveProfile
had already repointed the storage write path to the newly chosen profile by
then, so the app carried on showing the old profile while every later save went
to the new profile's slot, replacing its dashboards with the old one's content.

The write path is now left alone. The bootstrap sets it from the persisted name
on a reload that lands, so it always names the configuration that is actually in
memory. The persisted name is still written, so a switch whose reload does not
happen is deferred rather than lost.

The two names can now differ, so the consumers that meant "the profile on
screen" follow the loaded slot: resetSettings replaces the configuration the
user is looking at, and renameProfile moves the write path with the slot it
renames.
Not repointing the write path makes "loaded slot" and "persisted name" diverge
for the first time, and three consumers read the wrong one of the pair.

deleteProfile guarded only the loaded slot, so after a declined reload the
profile the device is about to boot into was an ordinary deletable row. Deleting
it left the persisted name on a slot that no longer exists and the next reload
landed on the degraded recovery screen; the old eager repoint had covered that
case by accident.

setRemoteContextDemand keyed by the persisted name while computing the answer
from the loaded profile's dashboards. A wrong key does not fail open, so the
next boot into that profile subscribes to no AIS targets.

renameProfile moved the write path after the old slot's removal had drained.
patchConfig bakes the slot name in at enqueue time, so a save queued in that
window targeted a slot the queue then deleted ahead of it.
@mairas
mairas force-pushed the fix/profile-repoint-after-reload branch from cda9154 to fd1b373 Compare August 13, 2026 08:04
@mairas
mairas merged commit 7521e8c into main Aug 13, 2026
4 checks passed
@mairas
mairas deleted the fix/profile-repoint-after-reload branch August 13, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A declined reload leaves saves pointed at the unloaded profile

1 participant