Skip to content

Persist scratch query buffers so a crash cannot take never-saved work - #498

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/scratch-buffer-persistence
Sep 3, 2026
Merged

Persist scratch query buffers so a crash cannot take never-saved work#498
erikdarlingdata merged 4 commits into
devfrom
fix/scratch-buffer-persistence

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

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.

  • One file per scratch buffer (.sql, so a user digging through their profile recognizes it) under a scratch directory 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.
  • Inline scratch:<guid> entries on the existing open_tabs list, 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 vacuous File.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 named scratch:something on a colon-tolerant filesystem still opens.
  • Content persists on a 2s idle debounce (keystroke-scale, separate from Persist the session continuously so restore survives the exits it exists for #495's 1s membership debounce), chained so a first-persist's minted entry lands together with its buffer; every Persist the session continuously so restore survives the exits it exists for #495 flush point drains both; no real timers under the test host (shared-dispatcher reasoning, same as Persist the session continuously so restore survives the exits it exists for #495).
  • Delete-on-choice hooks the resolution, not the dialog: the docked and detached prompt switches had drifted into near-twins, and rather than a third copy they now share one ResolveCloseChoiceAsync — so tab close, detached close, and the shutdown/restart walks honor "Don't Save" identically by construction. Save retires the buffer inside SaveQueryToPath. Consequence, tested: after a clean close, zero scratch buffers remain, because every one was chosen about.
  • Orphan sweep + poison parity with Persist the session continuously so restore survives the exits it exists for #495: unreferenced buffer files are swept at startup; a buffer that fails to load during restore is skipped, not re-added, and swept.
  • Detached scratch windows persist like docked ones. Scope fence stated in code: only scratch content is persisted — unsaved edits to file-backed tabs remain guarded by prompts, which is Persist scratch query buffer content across restarts #496's scope, not an oversight.

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 ScratchBufferPersistenceTests covering: 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, the scratch: 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

erikdarlingdata and others added 2 commits September 3, 2026 13:16
#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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the scratch-buffer persistence implementation (ScratchBufferStore, MainWindow.ScratchPersist.cs, and the open_tabs/ResolveCloseChoiceAsync changes).

No correctness, crash, or security issues found. Specifically checked and confirmed correct:

  • Ordering/races around mint-then-list: PersistScratchBuffer mints the buffer id and writes the file before RequestSessionPersist() fires, and FlushScratchBuffers() runs before the pending-list check in FlushSessionPersist, so a scratch:<guid> entry is never written before its backing file exists.
  • Drop-on-choice reentrancy: DropScratchBuffer is called from multiple paths that can overlap for the same session (ResolveCloseChoiceAsyncClose, then TryCloseTabAsync's own check; detached onClosing after a confirmed guard). It's correctly idempotent (if (session.ScratchBufferId is not { } id) return;), so the redundant calls are harmless no-ops.
  • Save-in-flight race: a Save As that lands between a scratch tab being queued for persist and the debounce flushing is handled — PersistScratchBuffer re-checks SourceFilePath != null at flush time and no-ops, since SaveQueryToPath already dropped the buffer.
  • Startup ordering: RestoreOpenPlans snapshots savedTabs before clearing/saving the list, sweeps orphans from that snapshot, then restores — so the orphan sweep and the poison-defense clear can't race each other or delete a buffer that's about to be restored.
  • Hook timing: HookScratchPersistence's scope-fence check (SourceFilePath != null → skip) is safe against load-order because every caller that opens a file-backed session (LoadSqlFile, plan loads) sets SourceFilePath before CreateTab runs, so a file tab is never transiently treated as scratch.
  • Backward/forward compat: the scratch: prefix can't collide with a real entry the app itself would ever write (always absolute paths), and old builds skip the new entries via their existing File.Exists guard — verified against both directions in ScratchBufferPersistenceTests.

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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed. This is confined to PlanViewer.App (+ its tests), so the Ssms version-bump and Blazor linked-Compile Include conventions don't apply, and there's no plan-XML/T-SQL surface touched.

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:

  • The scope fence (session.SourceFilePath != null bail) is checked consistently at every mutation site, so a file-backed session's buffer machinery is correctly inert.
  • DropScratchBuffer is idempotent and correctly ordered relative to IsShuttingDown (the OnClosed drain writes SaveOpenPlans() directly rather than relying on the gated RequestSessionPersist, so nothing is lost when the debounce flag is suppressed at shutdown).
  • Orphan sweep + poison-entry handling mirrors Persist the session continuously so restore survives the exits it exists for #495's existing discipline (unreferenced files collected at startup, failed loads skipped-not-readded-and-swept).
  • Old→new and new→old list compatibility is genuinely safe: the scratch: prefix contains a colon, which can't appear in a written path, so an old build's File.Exists guard skips these entries silently — and this is pinned with a shape assertion instead of a File.Exists check that would be vacuous on colon-tolerant filesystems.
  • Test coverage is unusually complete: compat in both directions, size cap (write + shed-when-over-cap), detached window persistence, orphan sweep, and poisoned/unreadable buffer entries are all exercised against real files under the redirected test-host directory.

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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

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 Directory.Build.props/vsixmanifest version bump needed (no Ssms/Core/Web changes), no new NoWarn.

Traced the scratch-buffer lifecycle in detail (ScratchBufferStore, MainWindow.ScratchPersist.cs, the ResolveCloseChoiceAsync consolidation, and the RestoreOpenPlans/orphan-sweep ordering):

  • Buffer identity (ScratchBufferId) is minted only at first persist and correctly guarded from re-arming after DropScratchBuffer (pending-set removal happens before the delete).
  • SaveQueryToPath drops the buffer after assigning SourceFilePath, so the DirtyStateChanged fired by the trailing MarkClean() is correctly ignored by the scratch hook's SourceFilePath == null guard — no re-mint-after-save race.
  • The IsShuttingDown gating is consistent: OnClosed calls FlushScratchBuffers() directly (bypassing FlushSessionPersist's early-return-on-shutdown) so the final drain still runs, and RequestSessionPersist() calls made from inside that drain are safe no-ops because SaveOpenPlans() right after reads live session state, not the pending flag.
  • Orphan sweep computes its referenced set from the pre-restore snapshot before any new sessions are created, so there's no race between the sweep and TryRestoreScratchTab minting matching IDs.
  • The scratch: prefix / colon-illegal-in-Windows-filenames compatibility argument for old builds checks out, and is correctly tested as a string-shape pin rather than a vacuous File.Exists.
  • Detach/redock and the shutdown force-close path for detached windows are correctly distinguished (onClosing vs onRedock callbacks), avoiding the double-drop / lost-buffer scenarios the code comments call out.

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.

@erikdarlingdata
erikdarlingdata merged commit e25e8c7 into dev Sep 3, 2026
3 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/scratch-buffer-persistence branch September 3, 2026 17:42
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.

1 participant