Surface the running instance on a bare second launch instead of clobbering settings - #494
Conversation
…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
| 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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| public void TheSentinelCanNeverBeMistakenForAFileByAnOldReceiver() | ||
| { | ||
| Assert.False( | ||
| File.Exists(SingleInstance.ActivateSentinel), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Reviewed the single-instance fix ( The design is sound overall — mutex-for-ownership + pipe-for-handoff, sentinel checked before
Everything else — the argv scrubbing, the |
…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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Reviewed the single-instance startup change ( 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. One thing worth verifying before calling this done, left as an inline comment: the whole fix rests on named 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
|
Reviewed the single-instance/mutex logic in Traced the full decision tree in Cross-checked No correctness bugs found. No inline comments to add — didn't find anything worth flagging. |
…#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
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.
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.::activate::on the existing pipe. Compatibility verified in both directions by reading the shipped handler: an old receiver'sFile.Existsguard drops the sentinel harmlessly (double-colons are illegal in Windows file names — pinned by a test), and the new receiver classifies the sentinel BEFOREFile.Existsand handles plain paths exactly as today, so the SSMS extension is untouched.--new-instanceescape hatch for deliberate second instances (informed last-write-wins), scrubbed both in Program.Main and insideOpenFromStartupArgs— MainWindow reads raw argv, so--new-instance file.sqlplanstill opens the file.File.Existsfor seconds.How was this tested?
Eleven new tests in
SingleInstanceTestspinning the dispatch seam (sentinel activates, path opens, nonexistent path ignored), the sentinel's cannot-be-a-file property, and argv scrubbing driven through the realOpenFromStartupArgsrouter. 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