Persist the session continuously so restore survives the exits it exists for - #495
Merged
Merged
Conversation
) Session restore had two gaps, both from writing the list only at clean close (#468's original scope): any abnormal exit -- crash, task kill, an OS "shut down anyway" past the dirty-tab prompt -- restored zero tabs, and a file-backed plan or query detached into its own window was never written down at all, because SaveOpenPlans walked MainTabControl alone. Membership changes now write the list as they happen, debounced one second so a burst (restore, Close All) lands as one write. The trigger rides the #481 TabContentWatcher rather than the call sites, for exactly that commit's reason: a persist remembered at sixteen call sites gets forgotten at the seventeenth. The two changes the strip cannot show get explicit calls -- the detached register (a window closing changes no tab) and SaveQueryToPath (a scratch gaining its first file changes no membership). Detached windows join the collected set through a second register next to #473's: the prompt register is query-sessions-only because only an edit can be lost, while persistence needs every FILE-backed detached window, plans included. Detached entries append after the docked tabs; on the next start they come back as ordinary docked tabs, deliberately not re-detached windows. The crash-loop defense is kept and sharpened: RestoreOpenPlans clears and saves the empty list BEFORE the first open (it used to clear after the loop, which only defended against crashes after restore finished), and each file that opens successfully re-enters through the debounced writer, which restore flushes synchronously at its end. Net invariant: a file that crashes the app during load never persists -- it died before its own re-add -- while everything that opened does. A crash mid-restore still loses the tabs opened before it (their re-add was pending, the UI thread never flushed it); accepted, and said so in the code. OnClosed keeps its save as the final authoritative write, now with the debounce timer stopped first so no tick lands in a torn-down window. Under the test host no real timer is armed at all -- the suite shares one dispatcher, and a stray tick would write one test's tabs over another's staged state -- so tests drive the flush through a deterministic seam and assert the redirected settings file (#451/#487). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
|
Reviewed the session-persistence changes ( Traced the mechanics closely for the failure modes that matter here — crash/kill mid-session, crash mid-restore, detach/redock ordering, and the scratch-gains-a-path case — and didn't find a correctness bug:
No T-SQL, versioned-project, or Blazor-linked-file changes in this PR, so those repo-convention checks don't apply. Nothing to flag — this looks solid. |
This was referenced Sep 3, 2026
Merged
rferraton
pushed a commit
to rferraton/PerformanceStudio
that referenced
this pull request
Oct 1, 2026
…#496) erikdarlingdata#495 made the open-tab list survive abnormal exits, which brought back every tab with a file behind it. The remaining loss was the tab that never had one: a scratch query - typed, never saved - lost its content to any crash, task kill, or OS "shut down anyway". Every interactive discard route already prompts (erikdarlingdata#462/erikdarlingdata#469/erikdarlingdata#473/erikdarlingdata#477), so the design center is the gap the prompts cannot cover: a buffer the user CHOSE to discard dies; a buffer they NEVER GOT TO CHOOSE about survives. Storage: one file per scratch buffer under a scratch/ directory beside the settings file, named by a stable per-session GUID minted at first persist and carried on the session object (QuerySessionControl .ScratchBufferId), written through AtomicFile. The directory rides AppSettingsService's test-host redirection (erikdarlingdata#487/erikdarlingdata#451), pinned in TestHostIsolationTests. Session list: scratch tabs enter the erikdarlingdata#495 open_tabs list as inline scratch:<guid> entries IN STRIP ORDER among the plain paths - no second list, no version field. Compatibility is pinned as a string property the way erikdarlingdata#494's sentinel lesson taught: the colon in the prefix means an old build's File.Exists guard skips the entry silently, and a new build reading an old list sees only paths and behaves exactly as before. Restore routes three ways: scratch entry -> dirty query tab recreated from its buffer with the same GUID; path -> OpenFileByExtension as always; unparseable -> treated as a path. Content cadence: a 2s idle debounce, deliberately separate from erikdarlingdata#495's 1s membership debounce (keystroke-scale vs click-scale), hooked through DirtyStateChanged for sessions that are scratch at CreateTab time, and drained at every erikdarlingdata#495 flush point (end of restore, OnClosed before the final list write, PersistSessionForRestart, the membership flush) plus its own tick, which chains the membership flush so a buffer and its entry land together. No real timer under the test host (shared dispatcher, same reasoning as erikdarlingdata#495); FlushPendingScratchPersistForTests is the deterministic seam. SCOPE FENCE: only scratch content persists - file-backed tabs' unsaved edits stay guarded by prompts alone. Delete-on-choice, hooked at the resolution rather than the dialog: the two near-twin choice switches (docked/detached) collapse into ResolveCloseChoiceAsync, where Don't Save drops the buffer; a successful SaveQueryToPath retires it (the real file owns the content now); closing a clean scratch tab or window sheds any stale buffer; Cancel changes nothing. A clean scratch is by construction an empty one, so after a clean close zero buffers remain - every buffer was chosen about. Orphan sweep at startup deletes unreferenced files (stranded buffers and AtomicFile .tmp strays alike), and a buffer that fails to load during restore is skipped, never re-added, and swept - the erikdarlingdata#495 poison invariant mirrored. Size cap ~1MB: past it the buffer is removed rather than left stale, and that one tab behaves pre-erikdarlingdata#496. Detached scratch windows persist like docked ones - the subscription and pending set are keyed on the session, which detach moves intact - and Don't Save at a detached prompt deletes the same way. Tests: 13 new (content-without-closing, restore continuity, interleaved order, Don't Save docked and detached, save conversion, clean-close zero buffers, emptied-tab shed, orphan sweep, poison parity, size cap, old-format list, prefix compat pin) plus the scratch-directory redirect pin. Suite: 484 total, 483 passed, 1 platform skip, run twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes #490.
Session restore had two gaps: a file-backed tab detached into its own window at exit was never persisted, and the open-tab list was written only at clean close — any abnormal exit (crash, task kill, OS "shut down anyway" past the dirty-tab prompt) restored zero tabs. Restore protection evaporated exactly when it was needed.
AtomicFilepath). The trigger hangs on Notice a plan arriving by every route, not just the one with a test (#447) #481'sTabContentWatcher— the one subscription that sees every open/close/restore/redock/content-swap, per that PR's own sixteen-call-sites rationale — plus exactly three explicit calls for what the strip cannot show: detach registration, detached-window close, and a tab gaining its first file path inSaveQueryToPath. The debounce also quietly does ordering work: a synchronous write at the watcher's moment would land in the gap betweenItems.Removeand the detached-register add and drop the detached file.RestoreOpenPlansstill clears the list — but now BEFORE the first open rather than after the loop, which closes a pre-existing hole: the old ordering meant a crash during restore left the poisoned list intact and looped forever. Each successfully opened tab re-enters via the watcher, and restore ends with a synchronous flush. Net invariant, tested: a file that crashes (or fails) during load never persists; every tab that opened successfully does. A crash mid-restore can lose earlier good tabs for that one start — stated in the doc comment as the accepted cost; the invariant is poison-never-persists.DispatcherTimerunder the harness — the suite shares one dispatcher, and a live timer would tick during a later test and clobber its staged settings. Tests drive an internal flush seam and assert the redirected settings file, not memory.How was this tested?
Eight new tests in
SessionPersistenceTestsplusRestoreQueryTabsTestsupdates: open-persists-without-close, close-removes, detached query/plan persisted with redock-exactly-once and detached-close-removes, post-restore immediate repopulation, the poison-exclusion invariant (a well-formed-but-invalid .sqlplan fails restore and is absent while the good file persists), and scratch-gains-file-joins-list. Existing tests whose "restore does not leak" assumption the new rewrite invalidated were fixed with proper seeding/cleanup rather than weakened. Full suite: 453 tests, 452 passed, 1 platform skip, 0 failed — in the worktree and again in the main checkout.🤖 Generated with Claude Code
https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n