Persist scratch query buffers so a crash cannot take never-saved work - #498
Conversation
#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 (#462/#469/#473/#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 (#487/#451), pinned in TestHostIsolationTests. Session list: scratch tabs enter the #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 #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 #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 #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 #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 #495 poison invariant mirrored. Size cap ~1MB: past it the buffer is removed rather than left stale, and that one tab behaves pre-#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
…e test The Avalonia sweep caught the one PutAwayMainWindow call that omitted the session, unlike its two siblings. On an assertion failure before the Don't Save click lands the scratch tab is still dirty, and a PutAway that skips MarkClean raises the #462 walk's modal during teardown - the leaked-window session poisoning #474's helper exists to prevent, biting exactly while masking the real failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
|
Reviewed the scratch-buffer persistence implementation ( No correctness, crash, or security issues found. Specifically checked and confirmed correct:
Test coverage is thorough — debounce/flush ordering, orphan sweep, poisoned/unreadable buffers, the size cap, detached windows, and old↔new list compatibility are all exercised via the redirected test-host storage. No T-SQL, plan-XML parsing, or PlanViewer.Ssms/PlanViewer.Web files are touched by this PR, so those repo-convention checks don't apply here. |
The session variable was declared inside the try, so referencing it from finally did not compile - the exact reason the test hoists window above the try, unmirrored. The broken intermediate commit shipped because a piped 'dotnet test | tail' reports the pipe's exit code, not the test run's; the repo documentation warns about precisely this, and this run was verified by its real exit code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
|
Reviewed. This is confined to Traced the full scratch-buffer lifecycle — hook/hydrate/debounce/flush/drop across tab close, detached close, save, save-as, shutdown, and restart — and it holds together:
No blocking findings. Nice work tracing the "chose to discard vs. never got to choose" distinction through every code path. |
…rash flows The code-review gate found three ways the orphan sweep or a drop could destroy scratch content the user never chose to discard — the exact invariant #496 exists to uphold. Blocking: a cold-start file-argument launch (Explorer double-click, SSMS with Studio closed) took an either/or branch that SKIPPED restore, so the continuous writer overwrote the saved list without the previous session's scratch entries and the next start's sweep deleted their buffers. OpenFromStartupArgs now restores first and opens the file on top, with the fallback scratch tab suppressed when a file is coming. Major: a mid-restore crash leaves the poison-cleared list empty, so the next start's sweep would delete every buffer including bystanders a different entry's crash stranded. The sweep is now age-gated (3-day grace) — a fresh orphan lingers as a recoverable .sql instead of dying; chosen deletions (Don't Save, Save) never come through the sweep, so they are unaffected. Major: the shutdown force-close drop could delete a buffer minted during the close walk (a detached scratch typed into while another window's modal prompt was up), leaving a dangling entry. Gated on !IsShuttingDown — OnClosed's final flush has already made every keep-or-drop decision by then. Minor: the trailing-edge debounce had no ceiling, deferring persistence indefinitely under continuous typing; a 10s max-latency cap now forces a write while keeping ordinary 2s debouncing once typing pauses. Two new tests (fresh-vs-aged sweep, file-arg cold start restoring the scratch and opening the file); the orphan test now backdates past the grace period, deriving the offset from the store's own constant. Also fixes the test-hygiene finding from the Avalonia sweep: the Don't Save test now puts its session away with its window like its siblings, so a failed assertion cannot leak a modal into later tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
|
Reviewed. This PR doesn't touch execution-plan XML parsing, operator-tree walking, or any generated T-SQL, so the top review priorities (untrusted plan XML, SQL injection via string concatenation) don't apply here — the surface is purely tab-session lifecycle in the Avalonia app. No Traced the scratch-buffer lifecycle in detail (
Didn't find a correctness bug or crash path. Test coverage (13 new tests) matches the scenarios actually exercised: idle-flush, restore identity, interleaved ordering, Don't-Save deletion, save-retires-buffer, clean-close invariant, orphan sweep aging, poison-load parity, size cap, old-format compat, and detached persistence. Nothing further to flag. |
What does this PR do?
Fixes #496 — the last gap in session survival: a scratch tab (typed, never saved) lost its content on any abnormal exit. Every interactive discard route already prompts (#469/#477), so the design center is the distinction between chose to discard and never got to choose: a buffer the user answered "Don't Save" about dies; one they never got asked about survives a crash and comes back as a dirty scratch tab.
.sql, so a user digging through their profile recognizes it) under ascratchdirectory beside the settings file, written atomically, riding the Stop the test harness from mutating real machine state #487 test-host redirection. Size-capped at 1MB so a pathological paste can't grind the writer. Privacy reasoned in the class doc: same profile, same sensitivity class as the recent-plans list and stored plan XML already there.scratch:<guid>entries on the existingopen_tabslist, preserving strip order (no second list reinventing interleaving). Compatibility is free and pinned the way Surface the running instance on a bare second launch instead of clobbering settings #494's sentinel lesson taught — as a string property, not a vacuousFile.Exists: an old build's path guard silently skips the entries (colon is illegal in Windows file names, and the app only writes absolute paths to that list); a new build reading an old list sees plain paths and behaves exactly as before. A prefixed entry whose tail is not a GUID is treated as a path, so a file literally namedscratch:somethingon a colon-tolerant filesystem still opens.ResolveCloseChoiceAsync— so tab close, detached close, and the shutdown/restart walks honor "Don't Save" identically by construction. Save retires the buffer insideSaveQueryToPath. Consequence, tested: after a clean close, zero scratch buffers remain, because every one was chosen about.One design-forced product improvement: scratch-persistence hooks subscribe only sessions that are scratch at hook time (a file-backed session can never become scratch), which kept the #473 glyph-leak reflection test's exact subscriber counts — fixed in the product rather than by loosening the test.
How was this tested?
Thirteen new tests in
ScratchBufferPersistenceTestscovering: idle-flush persistence without close, restore recreating a dirty scratch tab with identical content and the same GUID, interleaved entry order, Don't-Save deletion, Save converting entry to path and retiring the buffer, the clean-close zero-buffers invariant, orphan sweep, corrupt-buffer poison parity, the size cap, old-format plain-path lists restoring unchanged, thescratch:string-property compat pin, and detached scratch persistence. Full suite at dev tip: 484 tests, 483 passed, 1 platform skip, 0 failed — twice in the worktree and once in the main checkout.🤖 Generated with Claude Code
https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n