Skip to content

Surface the running instance on a bare second launch instead of clobbering settings - #494

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/single-instance-bare-launch
Sep 3, 2026
Merged

Surface the running instance on a bare second launch instead of clobbering settings#494
erikdarlingdata merged 3 commits into
devfrom
fix/single-instance-bare-launch

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Fixes #489.

A bare second launch ran a full second instance; both held whole-file AppSettings snapshots and Save writes the whole file, so whichever instance exited last silently clobbered the other's open_tabs and every other setting (AtomicFile prevents torn files, not lost updates). File-argument launches already forwarded over the pipe; the bare launch was exactly the clobber case.

  • Single-instance via named mutex (default Local\ scope, distinct from Lite's), acquired in Program.Main before Avalonia. The owner roots the handle in a static for the process lifetime — the commit documents the historical bug where a prior mutex attempt disposed its handle immediately and never actually held it. Mutex machinery failure degrades to pre-A plain second launch runs a second instance, and the two clobber each other's settings #489 full-launch behavior.
  • Version-skew-proof ordering: the with-file pipe probe stays FIRST — a running pre-mutex build answers its pipe but holds no mutex, and mutex-first would boot a second window beside it instead of handing the file over.
  • Surfacing sentinel ::activate:: on the existing pipe. Compatibility verified in both directions by reading the shipped handler: an old receiver's File.Exists guard drops the sentinel harmlessly (double-colons are illegal in Windows file names — pinned by a test), and the new receiver classifies the sentinel BEFORE File.Exists and handles plain paths exactly as today, so the SSMS extension is untouched.
  • --new-instance escape hatch for deliberate second instances (informed last-write-wins), scrubbed both in Program.Main and inside OpenFromStartupArgs — MainWindow reads raw argv, so --new-instance file.sqlplan still opens the file.
  • Delivery over loss: a non-owner that cannot reach the owner's pipe retries ~2s then runs fully — losing the user's double-clicked file (or the app appearing at all) is worse than a rare second instance. The residual two-simultaneous-bare-launches race is documented as the accepted floor.
  • Classification runs on the pipe task, not the dispatcher, because a dead UNC path can block File.Exists for seconds.

How was this tested?

Eleven new tests in SingleInstanceTests pinning the dispatch seam (sentinel activates, path opens, nonexistent path ignored), the sentinel's cannot-be-a-file property, and argv scrubbing driven through the real OpenFromStartupArgs router. The mutex/exit flow in Program.Main is process-level and verified by inspection (the harness boots App directly), with the reasoning in comments. Full suite: 456 tests, 455 passed, 1 platform skip, 0 failed; App builds clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n

…bbering settings (#489)

Two full instances each hold a whole-file AppSettings snapshot and Save
writes the whole file, so the instance that exits second silently
overwrites the other's open_tabs and every other setting - AtomicFile
prevents torn writes, not lost updates. Launches with a file argument
already forwarded to the running instance over the
SQLPerformanceStudio_OpenFile pipe; a bare second launch ran a full
instance and was exactly the clobber case.

Program.Main now claims a named mutex (SQLPerformanceStudio_SingleInstance,
default per-user-session scope, held for the process lifetime - the
previous mutex attempt died because its handle was disposed on return).
A launch that finds the slot taken hands its work to the owner over the
existing pipe - the file path it was given, or a reserved ::activate::
sentinel meaning "surface your main window" - and exits. The sentinel is
deliberately unrepresentable as a Windows file name: a pre-#489 receiver
File.Exists-guards every pipe line, so an old running build reads it,
finds no such file, and drops it without harm. The with-file pipe probe
stays first and unchanged, which also keeps version skew safe in the
other direction (an old running build answers its pipe but holds no
mutex).

If the owner never answers after ~2s of retries (wedged, or still
booting before its pipe server is up), the launch proceeds as a full
instance: losing the user's double-clicked file, or the app simply
appearing, is worse than a rare second instance. That is also the honest
residue of the startup race - two simultaneous bare launches can both
proceed if the loser's retries run out before the winner's pipe exists;
the remainder falls back to the old last-write-wins behavior as the
accepted floor.

--new-instance skips the single-instance check for users who run two on
purpose. It is scrubbed from argv both in Program.Main and inside
OpenFromStartupArgs, because MainWindow consumes the raw
Environment.GetCommandLineArgs() itself - so
"PerformanceStudio.exe --new-instance file.sqlplan" still opens the file.

The receiver's line dispatch is extracted into a testable seam
(SingleInstance.Classify + MainWindow.DispatchPipeMessage): sentinel
surfaces (UI-thread marshal, restore from minimized, Activate), an
existing path opens exactly as the SSMS extension has always relied on -
and now also restores a minimized window instead of loading into the
taskbar - and garbage is ignored as before. Headless tests pin the
grammar, the old-receiver degradation property, and the argv scrubbing;
the mutex/exit flow is process-level, out of the harness's reach (it
boots App directly, never Main), and is verified by inspection and
commented in Program.cs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Comment on lines +62 to +69
if (!newInstanceRequested)
{
// The pre-#489 forwarding, kept first and unchanged: a with-file launch tries
// the pipe before anything else. Beyond being the common case, probing before
// the mutex is version-skew-proof — an already-running build that predates the
// mutex answers its pipe but holds no mutex, and a mutex-first flow would run a
// second full window beside it instead of handing the file over.
if (effectiveArgs.Length > 0 && TrySendToRunningInstance(effectiveArgs[0], maxAttempts: 1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "probe-before-mutex is version-skew-proof" claim (comment above, lines 64-68) only holds for launches that carry a file argument. A bare launch skips this TrySendToRunningInstance probe entirely (effectiveArgs.Length > 0 is false) and goes straight to TryBecomeSingleInstanceOwner().

If the currently-running instance predates this change (or is otherwise a process that never claimed SingleInstance.MutexName), TryBecomeSingleInstanceOwner() succeeds (createdNew == true) because nobody holds the mutex yet. The if (!TryBecomeSingleInstanceOwner()) block — the only place that retries the pipe with the sentinel — is then skipped, and this launch falls straight through to StartWithClassicDesktopLifetime, running a full second instance alongside the old one.

That's exactly the #489 clobber scenario (two full instances, each with its own AppSettings snapshot, last exit wins) reproduced for the one combination the PR doesn't cover: a bare second launch during version skew. Every other case (with-file launch during skew, any launch once both sides are on this build) is handled correctly.

Given this is meant to close #489 completely, worth either probing the pipe unconditionally before claiming the mutex (send the sentinel when there's no file, exactly like the fallback retry does), or explicitly documenting this as an accepted gap the way the same-version race is documented at lines 88-92.

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.

Resolved in beacd5d as documentation, taking your second option: probing first on a bare launch can't work against an old receiver — it drops the sentinel while delivery reports success, so the launch would exit having surfaced nothing, which is worse than one transient window of the pre-#489 status quo. The comment now scopes the skew-proof claim to with-file launches and documents the bare-launch residue beside the same-version race, with the duplex-ack protocol named as what a real fix requires.

Comment on lines +79 to +82
public void TheSentinelCanNeverBeMistakenForAFileByAnOldReceiver()
{
Assert.False(
File.Exists(SingleInstance.ActivateSentinel),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test doesn't actually pin the invariant its own comment (and the class doc, lines ~340-345) claims: "the double-colons are illegal in Windows file names by design." CI (.github/workflows/ci.yml) runs dotnet test on ubuntu-latest, where : is a perfectly legal filename character — File.Exists("::activate::") here is only checking that no such file happens to exist in the test process's current working directory, not that the string is unrepresentable as a path.

Concretely: if someone later "tidies" ActivateSentinel into an ordinary legal token (the exact regression this test's XML doc says it guards against, e.g. "activate" instead of "::activate::"), this assertion still passes on Linux CI as long as no coincidental file named activate exists in the CWD — it gives no signal at all. The property that actually matters (illegal on Windows) is never exercised anywhere in 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 beacd5d — good catch, the File.Exists assertion proved only that CI's CWD was clean. The test now pins the string property directly (contains ':', the ::-delimited shape) so a tidied path-representable token fails on every platform, plus the actual Path.GetInvalidFileNameChars membership check on Windows where the guarantee lives. The old-Linux-receiver-with-adversarial-file edge is documented as the accepted floor: only pre-#489 builds are exposed, and only until they restart.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the single-instance fix (SingleInstance.cs, Program.cs, MainWindow.axaml.cs, SingleInstanceTests.cs). No SQL/plan-XML surface touched here, so most of the usual checklist doesn't apply.

The design is sound overall — mutex-for-ownership + pipe-for-handoff, sentinel checked before File.Exists so it can't collide with a real Linux path, --new-instance scrubbed at both consumption points. Two things worth a look, left as inline comments:

  1. Program.cs (lines 62-93): bare (no-file) launches skip the pipe probe entirely and go straight to TryBecomeSingleInstanceOwner(). During version skew — a pre-A plain second launch runs a second instance, and the two clobber each other's settings #489 build still running, which never claims the mutex — that call succeeds, so the retry-with-sentinel path never runs and this launch runs a full second instance beside the old one. That's the exact A plain second launch runs a second instance, and the two clobber each other's settings #489 clobber scenario, in the one combination (bare launch + version skew) the "probe-first is version-skew-proof" reasoning doesn't actually cover.
  2. SingleInstanceTests.cs (line 79-84): TheSentinelCanNeverBeMistakenForAFileByAnOldReceiver asserts File.Exists(ActivateSentinel) is false, but CI runs on ubuntu-latest where : is a legal filename character. The test only confirms no such file exists in the CWD right now — it doesn't pin the "illegal on Windows" property the doc comments describe, and wouldn't catch a regression where the sentinel is changed to an ordinary token.

Everything else — the argv scrubbing, the Classify grammar, the dispatch-restores-minimized-window behavior — looks correct and is exercised by the new tests.

…string property

Two gate findings. The version-skew-proof comment overclaimed: it holds
for with-file launches only, and a bare launch beside a pre-mutex build
deliberately runs fully rather than probing first - an old receiver
drops the sentinel while delivery reports success, so probe-first would
exit having surfaced nothing, worse than one transient window of the
pre-#489 status quo. Documented beside the same-version race instead of
solved; a real fix needs an acknowledged surfacing protocol.

The sentinel test was vacuous on the ubuntu CI runner, where colons are
legal in file names and File.Exists only proved the test CWD was clean.
The property that carries the old-Windows-receiver guarantee is the
colon itself, so it is now pinned as a string property (plus the actual
invalid-chars check where the guarantee lives), which catches a tidied
path-representable token on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
writer.WriteLine(filePath);
writer.Flush();
return true;
var mutex = new Mutex(initiallyOwned: true, SingleInstance.MutexName, out var createdNew);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Named Mutex cross-process detection is well-tested on Windows, but I don't see this exercised on Linux/macOS anywhere (the test suite explicitly says the process-level half is "verified by inspection" and can't run under the harness). If a named Mutex throws something other than the ACL/sandbox cases this comment anticipates (e.g. PlatformNotSupportedException, or any quirk in the Unix pthread-shared-memory shim under /tmp) on a given Linux/macOS build, the catch-all below returns true and every launch silently becomes its own "owner" — the single-instance protection this whole PR adds no-ops there, and the underlying #489 settings-clobber bug it's meant to fix would still reproduce on those platforms with no visible symptom pointing back to this code path. Worth a manual smoke test (.sqlplan double-click / bare launch, twice, on both Linux and macOS builds) before calling this closed, since CI can't catch 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.

Taken in the latest commit, converted from unobservable to two standing signals: NamedMutexMachineryWorksOnThisPlatform drives the named create and second-open paths on whatever platform the suite runs on — including the ubuntu CI runner on every push — so a shim that throws (PlatformNotSupportedException included) fails CI loudly instead of degrading invisibly; and the catch-all now reports the degradation on stderr, which is lost harmlessly by a Windows GUI launch and visible exactly on the terminal-launched Unix platforms the concern targets. The in-process test can't cover cross-process handoff, and macOS has no CI leg — the bare-double-launch smoke on a Mac is flagged as the remaining manual item for the release checklist.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed the single-instance startup change (Program.cs, MainWindow.axaml.cs, new SingleInstance.cs).

The state machine itself is sound: the with-file pipe probe staying first for version-skew safety, the sentinel being unrepresentable as a Windows file name so old receivers drop it harmlessly, closing the losing mutex handle immediately so it doesn't wedge a third launch, and the documented residual race (two simultaneous bare launches) are all correctly reasoned through and the tradeoffs are honest in the comments rather than hand-waved. Classify/StripNewInstanceFlag/dispatch are well covered by the new tests given what can actually run under the headless harness.

One thing worth verifying before calling this done, left as an inline comment: the whole fix rests on named Mutex working reliably for cross-process detection on Linux and macOS, and that half is explicitly untestable under the current harness ("verified by inspection"). If mutex creation throws for a reason other than the ACL/sandbox case anticipated in the catch block, every launch silently falls back to pre-#489 behavior with no visible signal — which is exactly the bug #489 reports, just moved to non-Windows. Since this app ships cross-platform, a manual double-launch smoke test on both platforms seems warranted before merge.

No T-SQL, versioning, or PlanViewer.Web linkage concerns — this PR doesn't touch any of those surfaces.

…him in CI

The gate's third finding: if the named mutex throws wholesale on some
platform, the catch-all's run-fully degradation would silently no-op
single-instancing and #489's clobber would be back with no symptom
pointing here. Two signals now exist. A unit test drives the named
create and second-open paths on whatever platform the suite runs on -
the ubuntu runner on every push - so a shim that throws fails CI loudly
before it degrades invisibly in the field. And the catch-all says so on
stderr, which a Windows GUI launch loses harmlessly while the Unix
platforms most likely to hit it are exactly where terminal launches are
common. macOS has no CI leg; the bare-double-launch smoke there is a
release-checklist item.

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 single-instance/mutex logic in Program.cs, SingleInstance.cs, and MainWindow.axaml.cs.

Traced the full decision tree in Program.Main (bare vs with-file launch, mutex-owner vs non-owner, retry/fallback, --new-instance bypass) and it's internally consistent — no path loses a file or double-opens one, and the "run fully" fallback correctly leaves _singleInstanceMutex unset so it doesn't falsely claim ownership it never won. TryBecomeSingleInstanceOwner's dispose-on-not-created avoids the historical bug (handle held past method return) that the PR description calls out. The sentinel-before-File.Exists ordering in Classify correctly handles the Linux edge case (: is a legal filename character there) even though it only matters for a hypothetical adversarial/coincidental file, which the tests and comments already call out honestly as an accepted floor.

Cross-checked PlanViewer.Ssms/AppLauncher.cs — it only ever writes a plain file path to the pipe, never anything resembling ::activate::, so the compatibility claim (old SSMS sender / old receiver both stay safe) holds.

No correctness bugs found. PipeName references are fully migrated to SingleInstance.PipeName (no dangling old constant). Test coverage is honest about its own limits — the process-level mutex/exit flow can't run under the headless test harness and is verified by inspection, which is a reasonable tradeoff given the harness constraint, not a coverage gap being hidden.

No inline comments to add — didn't find anything worth flagging.

@erikdarlingdata
erikdarlingdata merged commit 07f016c into dev Sep 3, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/single-instance-bare-launch branch September 3, 2026 14:37
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