Skip to content

Close three ways the app could quietly lose a user's work - #485

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/data-loss-routes
Sep 3, 2026
Merged

Close three ways the app could quietly lose a user's work#485
erikdarlingdata merged 3 commits into
devfrom
fix/data-loss-routes

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Fixes the three Major data-loss findings from the 2026-09-02 adversarial review:

  • Velopack "Restart Now" bypassed every guard: ApplyUpdatesAndRestart exits without raising Closing, so the Warn about unsaved query changes, and mark modified tabs #462/Detached windows discard unsaved query changes without asking #473 unsaved-changes walk never ran and the session save never happened — dirty edits silently discarded and the updated app relaunched with no tabs. The walk now lives in ConfirmAllUnsavedWorkAsync (pure: no close bookkeeping), reused by the close path and run by the About window before restarting; Cancel aborts the restart with the update still downloaded, and the session is persisted after the walk since a Save answer can give a scratch tab a path worth restoring.
  • Save-in-place was truncate-then-write over the user's only copy. SaveQueryToPath now stages through the existing AtomicFile (sibling .tmp + rename), so a failed save leaves the original bytes on disk and the session dirty. Attribute/ACL trade documented in a comment.
  • "Open in Query Editor" overwrote a dirty buffer unconditionally — the one wholesale replacement that skipped dirty tracking. It now confirms (Replace/Cancel) via the existing ConfirmationDialog when the editor holds typed-but-unsaved work; clean or empty editors replace without a prompt, exactly as before. The decision is a pure testable seam (ReplaceNeedsConfirmation).

How was this tested?

Nine new tests across UpdateRestartGuardTests, OpenInEditorOverwriteTests, and extended OpenSaveQueryTests — including a save that cannot stage its temp leaving the original untouched (that save succeeded under the old code). Full suite at dev tip + fix: 417 tests, 416 passed, 1 platform skip, 0 failed, on Windows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n

An adversarial review of the unsaved-changes work turned up three
data-loss routes that all bypassed the guards #462 and #473 built:

- The About window's Velopack "Restart Now" called
  ApplyUpdatesAndRestart, which exits the process without ever raising
  Closing. The unsaved-changes walk never ran, so dirty edits were
  discarded without a question - and OnClosed's session save never ran
  either, so with the saved tab list already cleared at startup the
  updated app relaunched empty-handed. The walk now lives in
  ConfirmAllUnsavedWorkAsync, reused by the close path and run by the
  About window before the restart; a Cancel aborts the restart with the
  update still downloaded, and PersistSessionForRestart writes the open
  tabs down after the walk (a Save answer can give a scratch tab a file
  worth restoring).

- SaveQueryToPath wrote the user's file with a plain truncate-then-
  write, so a save that died halfway - disk full, crash - destroyed the
  only copy of the file it was trying to update. It now stages through
  AtomicFile like the settings writers already did: sibling .tmp, then
  rename over the top, so a failed save leaves the original bytes on
  disk and the session dirty.

- "Open in Query Editor" pasted a plan's statement over the editor
  unconditionally - the one wholesale overwrite that skipped the dirty
  tracking entirely. It now confirms (Replace/Cancel) when the editor
  holds typed-but-unsaved work; a clean or empty editor replaces
  without a prompt, exactly as before. ConfirmationDialog rather than
  the three-button UnsavedChangesDialog, because a Save answer would
  need the save pipeline that lives on MainWindow; the dialog's fixed
  button width became a minimum so the Replace caption is not clipped.

Tests pin the walk (a clean window answers yes without a prompt, a
dismissed prompt refuses with everything intact), the restart
persistence, save-in-place round-tripping with no staging file left
behind, a save that cannot stage leaving the original untouched, and
all three editor-replacement paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Comment thread src/PlanViewer.App/AboutWindow.axaml.cs Outdated
Comment on lines 241 to 251
if (Owner is MainWindow main)
{
if (!await main.ConfirmAllUnsavedWorkAsync())
return;

/* After the walk, not before: a Save answer in the walk can give a
scratch tab a file, which this then writes down for the restore. */
main.PersistSessionForRestart();
}

_velopackMgr.ApplyUpdatesAndRestart(_velopackUpdate.TargetFullRelease);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateLink is never disabled while this await is in flight. Before this change the gap between click and ApplyUpdatesAndRestart was one dialog; now it can be a whole walk over every dirty tab and detached window, each awaiting its own UnsavedChangesDialog. A second click on UpdateLink during that window re-enters UpdateLink_Click (the _updateDownloaded/_velopackMgr/_velopackUpdate guard is still satisfied), starting a second, concurrent ConfirmAllUnsavedWorkAsync() walk over the same tabs/paths — two UnsavedChangesDialogs racing for the same tab, potentially two AtomicFile writes to the same path, and possibly ApplyUpdatesAndRestart firing twice. Worth disabling the link (or setting an in-flight guard) for the duration of the confirm-and-restart sequence.

Separately: the confirmation dialogs this walk raises (UnsavedChangesDialog via ConfirmCloseAsync/ConfirmDetachedCloseAsync) are parented to main (MainWindow), while this (AboutWindow) is the window actually on top and stays open and interactive the whole time. That's exactly the failure mode ConfirmDetachedCloseAsync's own comment calls out ("a prompt parented to a window behind it is a prompt nobody can see") — here the user can still click around the still-open About window while a data-loss-preventing prompt is only modal relative to the window behind it.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f9381f — one _updateActionInFlight latch at the top of UpdateLink_Click covers every awaiting branch (dialog, walk, download), so a second click is a no-op rather than a concurrent copy of whichever step is in flight.

Comment thread src/PlanViewer.App/AboutWindow.axaml.cs Outdated
(RestoreOpenPlans had already cleared the saved list at startup). Ask the
same questions the close path asks, and if anyone answers Cancel, abort
the restart and leave this window usable — the update stays downloaded. */
if (Owner is MainWindow main)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (Owner is MainWindow main) silently skips the entire unsaved-work walk and falls straight through to _velopackMgr.ApplyUpdatesAndRestart(...) if Owner is ever not a MainWindow — i.e. this is a silent fallback to exactly the pre-fix, data-losing behavior this PR is closing. It happens to always be true today (both call sites construct AboutWindow and ShowDialog it with the app's single MainWindow), but there's no assertion guarding that invariant, so a future refactor that shows AboutWindow differently reintroduces the bug with no compiler or test signal.

Also worth noting: none of the 9 new tests drive this method itself — UpdateRestartGuardTests calls window.ConfirmAllUnsavedWorkAsync() and window.PersistSessionForRestart() directly, never through AboutWindow.UpdateLink_Click. That's reasonable given headless UI can't click the "Restart Now" link, but it does mean the actual wiring here (order of the two calls, the Owner cast, the early return on cancel) is unverified by the suite.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f9381f — the main window is now resolved through the application lifetime (Owner as the fast path), so the guard can't silently vanish under a different owner. If no main window exists at all there are no sessions to lose, so restarting without a walk is genuinely safe; the comment states the fail-open hazard explicitly.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the diff. The three fixes are well-targeted and the extraction of ConfirmAllUnsavedWorkAsync / ReplaceNeedsConfirmation as pure/testable seams is clean — the SaveQueryToPathAtomicFile switch is a real fix for a real data-loss bug (truncate-then-write on the user's only copy), and it's backed by a test that actually forces the staging write to fail and asserts the original bytes survive. ReplaceNeedsConfirmation's "dirty but empty" carve-out is a sensible, deliberate choice and is tested.

Left two inline comments on AboutWindow.axaml.cs, both around the new if (Owner is MainWindow main) block:

  1. Reentrancy: UpdateLink isn't disabled during the await. What used to be a single dialog before ApplyUpdatesAndRestart is now a potentially multi-dialog walk across every dirty tab/detached window — a second click during that window re-enters UpdateLink_Click and can start a second concurrent confirmation walk over the same tabs (and, in the worst case, call ApplyUpdatesAndRestart twice).
  2. Dialog ownership: the walk's UnsavedChangesDialog prompts are parented to main (MainWindow), not to this (AboutWindow) — but AboutWindow is the window that's actually visible and stays open/interactive throughout. That's the same failure mode ConfirmDetachedCloseAsync's own comment warns about for detached windows, just not applied here.

Also flagged: the Owner is MainWindow main check is a silent no-op fallback to the old (data-losing) behavior if Owner is ever not a MainWindow, and none of the 9 new tests exercise AboutWindow.UpdateLink_Click itself — they all call ConfirmAllUnsavedWorkAsync/PersistSessionForRestart directly, so the actual wiring in this method is unverified by the suite.

Nothing else stood out — no untrusted-XML/T-SQL surface touched here, no version-bump files touched, no new warnings apparent from the diff.

…losed

Two findings from the gate review of the restart guard:

- Every branch of UpdateLink_Click awaits (dialog, unsaved-work walk,
  download) with the link still clickable, and the walk made the window
  between click and restart arbitrarily long - a second click started a
  concurrent copy of whichever step was in flight. One latch at the top
  now covers all branches.

- The walk was gated on 'Owner is MainWindow', which fails OPEN: shown
  with any other owner, the guard silently vanishes and the route is
  back to discarding dirty edits. The main window is now resolved
  through the application lifetime with Owner as the fast path; if no
  main window exists at all there are no sessions to lose, so restarting
  without a walk is genuinely safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Comment on lines +174 to +190
internal async void OnOpenInEditorRequested(object? sender, string queryText)
{
/* This used to assign unconditionally — the one wholesale overwrite in the app that
skipped #462's dirty tracking, so a typed-but-unsaved query was replaced without a
question. ConfirmationDialog rather than the three-button UnsavedChangesDialog:
a Save answer here would need the save pipeline, which lives on MainWindow and
takes the tab — machinery this control has no business growing for one prompt.
Dismissing the dialog is a no, and a no leaves the editor and the sub-tab alone. */
if (ReplaceNeedsConfirmation(IsDirty, QueryEditor.Text))
{
var replace = await ShowConfirmationDialog(
"Unsaved Changes",
"The query editor has unsaved changes.\n\nReplace them with this statement? Your current text will be lost.",
confirmCaption: "Replace");

if (!replace)
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this handler now has an await between the dirty check and the text replacement, but nothing stops it from running twice concurrently. A double-click (or two statements' "Open in Editor" clicked back-to-back) on a dirty editor fires two overlapping calls, each reading the same IsDirty/QueryEditor.Text and each popping its own ConfirmationDialog — you can end up with two stacked "Replace?" prompts.

Not data-loss (the eventual QueryEditor.Text = queryText assignment is still gated by an explicit Replace click either way), but it's the same class of bug this PR just fixed in AboutWindow.UpdateLink_Click with the _updateActionInFlight latch. Worth a similar guard here for consistency, or at least worth confirming it's an accepted gap.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6213ab4 — same latch pattern as the update link: _replacePromptInFlight makes the second click a no-op while the prompt is up, with the flag cleared in finally so a dialog exception can't wedge the feature.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed both commits. This is a well-scoped, well-tested fix for the three data-loss routes described in the PR body, and the second commit already closes the two follow-up gaps (click-latch, fail-closed Owner resolution) a gate review would otherwise flag.

Walked through the changed paths specifically for the things this repo cares about:

  • AtomicFile.WriteAllText reuse in SaveQueryToPath is genuinely atomic (sibling .tmp + File.Move(overwrite:true), same filesystem guaranteed since it's a sibling path) and the new ASaveThatCannotStageItsTempLeavesTheOriginalFileAlone test actually forces the staging write to fail and asserts the original bytes/dirty flag survive — good adversarial coverage.
  • ConfirmAllUnsavedWorkAsync is a faithful extraction of the existing close-walk (no behavior change to ConfirmWindowCloseAsync), and the About-window restart path now runs the same walk before ApplyUpdatesAndRestart with PersistSessionForRestart() called after the walk (so a Save answer that hands a scratch tab a path is captured) — matches the PR description exactly.
  • Owner as MainWindow ?? ... IClassicDesktopStyleApplicationLifetime ... MainWindow correctly fails closed rather than open — this was the right fix for a guard that previously silently vanished for any non-MainWindow owner.
  • _updateActionInFlight latch wraps the entire click handler (download step included), preventing the concurrent-invocation issue it targets.
  • No T-SQL, no Directory.Build.props/vsixmanifest version touches, no new NoWarn — none of the other repo-convention gates apply to this diff.

Left one inline note: OnOpenInEditorRequested introduces the same "await sits between the read and the mutation" shape that UpdateLink_Click needed a latch for, but this one has no guard against a rapid double-click firing two overlapping confirmation prompts. It's not a data-loss path (the actual replace is still gated on an explicit click either way), so it's a minor consistency nit rather than a blocker.

Same reentrancy class the previous commit fixed in AboutWindow: the
Replace confirmation put an await between the dirty check and the
assignment, so two back-to-back Open in Query Editor clicks stacked two
prompts over the same buffer. Not data loss - the assignment stays gated
on an explicit Replace - but the first click now wins and the second is
a no-op while the prompt is up.

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 diff. This is a tight, well-scoped fix for the three data-loss routes described, and the changes hold up:

  • Restart-now guard: HandleUpdateLinkClickAsync correctly resolves MainWindow via the app lifetime (not just Owner), so the walk doesn't silently vanish if the About window is ever parented differently. ConfirmAllUnsavedWorkAsync calls ConfirmCloseAsync/ConfirmDetachedCloseAsync directly rather than TryCloseTabAsync, so tabs are asked-about/saved but not removed from the strip — exactly right for a route that doesn't actually close the window before the process exits via Velopack. PersistSessionForRestart running after the walk (so a Save-As on a scratch tab is captured) is correct.
  • Save-in-place via AtomicFile: reuses the existing .tmp-then-rename helper already used by SettingsFile/AppSettingsService/ConnectionStore, so no new atomic-write logic to scrutinize. The new "staging write fails, original bytes survive" test actually exercises the failure path (a directory squatting on the .tmp name), not just the happy path.
  • "Open in Query Editor" confirmation: ReplaceNeedsConfirmation correctly treats dirty-but-empty as "nothing to lose," matching the existing CollectOpenTabPaths-style pure-decision pattern, and is unit tested directly.
  • Both new reentrancy latches (_updateActionInFlight, _replacePromptInFlight) are simple and correctly scoped with try/finally; the previous behavior for the no-prompt paths (clean editor, no unsaved work) is unchanged and stays synchronous/undelayed.

No untrusted-input, SQL-generation, or repo-convention issues apply here (no T-SQL, no version bump, no PlanViewer.Core/Web linkage touched). Didn't spot any correctness gaps in the close/restart/save control flow worth blocking on.

@erikdarlingdata
erikdarlingdata merged commit 4150766 into dev Sep 3, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/data-loss-routes branch September 3, 2026 09:25
@erikdarlingdata erikdarlingdata mentioned this pull request Sep 3, 2026
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