-
Notifications
You must be signed in to change notification settings - Fork 35
Surface the running instance on a bare second launch instead of clobbering settings #494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7507f06
beacd5d
46292fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| using System; | ||
| using System.IO; | ||
| using System.IO.Pipes; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using PlanViewer.App.Services; | ||
| using Velopack; | ||
|
|
@@ -10,7 +11,14 @@ namespace PlanViewer.App; | |
|
|
||
| class Program | ||
| { | ||
| private const string PipeName = "SQLPerformanceStudio_OpenFile"; | ||
| /// <summary> | ||
| /// Held — never released — by the instance that owns the single-instance slot (#489). | ||
| /// The OS tears the mutex down when the process exits, crash included, so there is no | ||
| /// release path to get wrong; a static field keeps the handle rooted for the whole run | ||
| /// (the previous mutex attempt died precisely because its handle was disposed the | ||
| /// moment the acquiring method returned, so no instance ever actually held it). | ||
| /// </summary> | ||
| private static Mutex? _singleInstanceMutex; | ||
|
|
||
| [STAThread] | ||
| public static void Main(string[] args) | ||
|
|
@@ -36,12 +44,72 @@ public static void Main(string[] args) | |
| } | ||
| velopack.Run(); | ||
|
|
||
| // If another instance is running, send the file path to it and exit | ||
| if (args.Length > 0 && TrySendToRunningInstance(args[0])) | ||
| return; | ||
| /* #489: every instance holds a whole-file AppSettings snapshot and Save writes the | ||
| whole file, so a second instance makes the settings file last-write-wins — the | ||
| instance that exits second silently clobbers the other's open_tabs and every | ||
| other setting (AtomicFile prevents torn writes, not lost updates). File-argument | ||
| launches already forwarded to the running instance over the pipe; a BARE second | ||
| launch ran a full instance and was exactly the clobber case. So unless the user | ||
| explicitly asks for a second instance, a launch that finds one running hands it | ||
| its work — a file path, or a bare "surface yourself" — and exits. */ | ||
| var newInstanceRequested = SingleInstance.NewInstanceRequested(args); | ||
|
|
||
| // The flag is a launcher directive, not a file: strip it so nothing downstream can | ||
| // mistake it for a path ("PerformanceStudio.exe --new-instance file.sqlplan" must | ||
| // still open the file). MainWindow re-reads the raw argv and scrubs it again itself. | ||
| var effectiveArgs = SingleInstance.StripNewInstanceFlag(args); | ||
|
|
||
| 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 makes the WITH-FILE path 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. | ||
|
|
||
| A BARE launch during that same skew is the one #489 case deliberately left | ||
| open: it cannot probe first, because an old receiver silently drops the | ||
| sentinel (File.Exists guard) while delivery still reports success — the | ||
| launch would exit having surfaced nothing, which is worse than a second | ||
| instance. So a bare launch beside a pre-mutex build claims the free mutex | ||
| and runs fully: the pre-#489 status quo, for one transient upgrade window | ||
| that ends when the old instance exits. Documented rather than solved; a | ||
| real fix needs an acknowledged (duplex) surfacing protocol. */ | ||
| if (effectiveArgs.Length > 0 && TrySendToRunningInstance(effectiveArgs[0], maxAttempts: 1)) | ||
| return; | ||
|
|
||
| if (!TryBecomeSingleInstanceOwner()) | ||
| { | ||
| /* Another instance owns the slot but hasn't answered its pipe yet — bare | ||
| launches never probed above, and a with-file probe may have raced the | ||
| owner's boot (the pipe server starts in the MainWindow constructor, | ||
| which on a cold start is seconds after its Main). Retry for ~2s before | ||
| giving up on delivery. */ | ||
| var message = effectiveArgs.Length > 0 | ||
| ? effectiveArgs[0] | ||
| : SingleInstance.ActivateSentinel; | ||
| if (TrySendToRunningInstance(message, maxAttempts: 4)) | ||
| return; | ||
|
|
||
| /* Delivery failed after retries: the owner is wedged, exiting, or still | ||
| booting slowly. Losing the user's action — their double-clicked file, or | ||
| the app simply appearing at all — is worse than a rare second instance, | ||
| so fall through and run fully. This is also the honest residue of the | ||
| startup race: two simultaneous bare launches can BOTH end up proceeding | ||
| when the loser's retries run out before the winner's pipe exists. The | ||
| mutex closes most of that window; what remains falls back to the | ||
| pre-#489 last-write-wins behavior, which is the accepted floor. */ | ||
| } | ||
| } | ||
|
|
||
| BuildAvaloniaApp() | ||
| .StartWithClassicDesktopLifetime(args); | ||
| .StartWithClassicDesktopLifetime(effectiveArgs); | ||
|
|
||
| // Reached only at app shutdown. Statics are GC roots, so the field alone keeps the | ||
| // owner's mutex handle alive; this read exists to say out loud that the handle's | ||
| // LIFETIME is the point (and to keep the field from reading as write-only). | ||
| GC.KeepAlive(_singleInstanceMutex); | ||
| } | ||
|
|
||
| // Avalonia configuration, don't remove; also used by visual designer. | ||
|
|
@@ -52,32 +120,87 @@ public static AppBuilder BuildAvaloniaApp() | |
| .LogToTrace(); | ||
|
|
||
| /// <summary> | ||
| /// Tries to hand the file path to an already-running instance over its named pipe. | ||
| /// A failed/timed-out connect means no instance is listening, so the caller should | ||
| /// launch normally. Returns true only if the path was actually delivered. | ||
| /// Tries to claim the single-instance slot (#489). True means this process is the | ||
| /// owner and should run; false means another instance holds the slot and this launch | ||
| /// should hand its work over instead. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Detection is via the pipe itself rather than a named mutex: the previous mutex | ||
| /// was disposed as soon as this method returned, so no instance ever held it and | ||
| /// the forwarding path was never taken. | ||
| /// </remarks> | ||
| private static bool TrySendToRunningInstance(string filePath) | ||
| private static bool TryBecomeSingleInstanceOwner() | ||
| { | ||
| try | ||
| { | ||
| using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); | ||
| // Short timeout: a running instance's listener is idle and connects | ||
| // immediately; when none is running this is the only added launch delay. | ||
| client.Connect(500); | ||
| using var writer = new StreamWriter(client); | ||
| writer.WriteLine(filePath); | ||
| writer.Flush(); | ||
| var mutex = new Mutex(initiallyOwned: true, SingleInstance.MutexName, out var createdNew); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Named
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| if (createdNew) | ||
| { | ||
| _singleInstanceMutex = mutex; | ||
| return true; | ||
| } | ||
|
|
||
| /* Another process owns it. Close our handle right away: if this launch ends up | ||
| running anyway (the pipe fallback above), a lingering handle would keep the | ||
| kernel object alive after the real owner exits, and a THIRD launch would then | ||
| see the name taken with nobody serving the pipe behind it. */ | ||
| mutex.Dispose(); | ||
| return false; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| /* Mutex machinery unavailable — an ACL mismatch on the name, a restrictive | ||
| sandbox, or a platform where the named-mutex shim misbehaves (on Unix these | ||
| are file-backed under /tmp; PlatformNotSupportedException would land here | ||
| too). Claiming ownership is the conservative answer: this instance runs | ||
| fully, which is exactly the pre-#489 behavior for every launch. | ||
|
|
||
| Said out loud rather than swallowed, because the degradation is otherwise | ||
| invisible: if this fires on every launch, single-instancing has quietly | ||
| no-oped and #489's settings clobber is back with no symptom pointing here. | ||
| stderr is the right channel — a Windows GUI launch has no console and loses | ||
| it harmlessly, while the Unix platforms this is most likely to fire on are | ||
| exactly where launching from a terminal is common. A unit test exercises | ||
| the named-mutex machinery per platform in CI so a shim that throws fails | ||
| loudly there first. */ | ||
| Console.Error.WriteLine( | ||
| $"PerformanceStudio: single-instance detection unavailable ({ex.GetType().Name}); running as a full instance."); | ||
| return true; | ||
| } | ||
| catch | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tries to hand one line — a file path, or the activation sentinel — to an | ||
| /// already-running instance over its named pipe. Returns true only if the line was | ||
| /// actually delivered; the caller decides what a failed delivery costs. | ||
| /// </summary> | ||
| private static bool TrySendToRunningInstance(string message, int maxAttempts) | ||
| { | ||
| for (var attempt = 1; attempt <= maxAttempts; attempt++) | ||
| { | ||
| // No instance listening (or pipe busy) — fall through to launch normally. | ||
| return false; | ||
| if (attempt > 1) | ||
| { | ||
| // Between attempts only: the owner is presumably mid-boot, so give its | ||
| // pipe server a beat to come up rather than burning connects back-to-back. | ||
| Thread.Sleep(100); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| using var client = new NamedPipeClientStream(".", SingleInstance.PipeName, PipeDirection.Out); | ||
| // 500ms per attempt: a running instance's listener is idle and connects | ||
| // immediately, while Connect burns the full timeout when nothing is | ||
| // listening — so the single-attempt probe on a with-file launch adds at | ||
| // most the same half second it always has, and the 4-attempt retry path | ||
| // totals roughly the ~2s boot grace it exists for. | ||
| client.Connect(500); | ||
| using var writer = new StreamWriter(client); | ||
| writer.WriteLine(message); | ||
| writer.Flush(); | ||
| return true; | ||
| } | ||
| catch | ||
| { | ||
| // Not listening yet, or the single server slot was mid-conversation with | ||
| // another client — retry if the budget allows, otherwise report undelivered. | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
TrySendToRunningInstanceprobe entirely (effectiveArgs.Length > 0is false) and goes straight toTryBecomeSingleInstanceOwner().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. Theif (!TryBecomeSingleInstanceOwner())block — the only place that retries the pipe with the sentinel — is then skipped, and this launch falls straight through toStartWithClassicDesktopLifetime, running a full second instance alongside the old one.That's exactly the #489 clobber scenario (two full instances, each with its own
AppSettingssnapshot, 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.
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.