diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs
index 2f3e066e..41fdf6b6 100644
--- a/Dashboard/App.xaml.cs
+++ b/Dashboard/App.xaml.cs
@@ -22,20 +22,39 @@ namespace PerformanceMonitorDashboard
public partial class App : Application
{
private const string MutexName = "PerformanceMonitorDashboard_SingleInstance";
- private Mutex? _singleInstanceMutex;
- private bool _ownsMutex;
+ /* Version-aware single-instance + upgrade handoff (plans/single-instance-upgrade-handoff.md):
+ a newer build launched over an older tray-resident one closes it and takes over instead of
+ being handed back the stale in-memory version. The coordinator owns the mutex + the
+ exit-for-upgrade listener for the life of the owning process. */
+ private const string ExitForUpgradeEventName = "PerformanceMonitorDashboard_ExitForUpgrade";
+ private SingleInstanceCoordinator? _instanceCoordinator;
protected override void OnStartup(StartupEventArgs e)
{
NativeMethods.SetAppUserModelId("DarlingData.PerformanceMonitor.Dashboard");
- // Check for existing instance
- _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex);
-
- if (!_ownsMutex)
+ /* Single-instance with upgrade handoff. Runs synchronously at the top of OnStartup before
+ base.OnStartup and any window/MCP init, so a stale older build is closed before we bind
+ the MCP port / touch shared config. A same/newer instance just surfaces (today's behavior
+ via WM_SHOWMONITOR); an older-but-elevated one raises an actionable error. */
+ _instanceCoordinator = new SingleInstanceCoordinator(new SingleInstanceOptions
+ {
+ MutexName = MutexName,
+ ProcessName = "PerformanceMonitorDashboard",
+ ExitEventName = ExitForUpgradeEventName,
+ SurfaceRunningInstance = NativeMethods.BroadcastShowMessage,
+ GracefulSelfExit = () => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (MainWindow is MainWindow mw) mw.ExitApplication();
+ else Shutdown();
+ })),
+ Prompts = new MessageBoxHandoffPrompts("Performance Monitor Dashboard"),
+ AutoConfirm = Array.Exists(e.Args, a => string.Equals(a, HandoffArgs.AutoConfirm, StringComparison.OrdinalIgnoreCase)),
+ Log = msg => { try { Logger.Info($"[SingleInstance] {msg}"); } catch { /* logger not yet initialized */ } },
+ });
+
+ if (!_instanceCoordinator.TryBecomeOwner())
{
- // Another instance is already running - activate it and exit
- NativeMethods.BroadcastShowMessage();
Shutdown();
return;
}
@@ -73,6 +92,13 @@ protected override void OnStartup(StartupEventArgs e)
mainWindow.Show();
}
+ ///
+ /// Opens the upgrade-handoff "exit" channel once startup is past its risky init. Called by
+ /// after initialization so a newer build won't signal/kill us mid-init
+ /// (#single-instance-upgrade-handoff). Safe to call more than once.
+ ///
+ public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff();
+
protected override void OnExit(ExitEventArgs e)
{
Logger.Info($"=== Application Exiting (Exit Code: {e.ApplicationExitCode}) ===");
@@ -83,11 +109,8 @@ protected override void OnExit(ExitEventArgs e)
mainWin.ExitApplication();
}
- if (_ownsMutex)
- {
- _singleInstanceMutex?.ReleaseMutex();
- }
- _singleInstanceMutex?.Dispose();
+ /* Releases the mutex + disposes the exit-for-upgrade listener. */
+ _instanceCoordinator?.Dispose();
base.OnExit(e);
}
diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs
index 54b811a3..ce828d9b 100644
--- a/Dashboard/MainWindow.xaml.cs
+++ b/Dashboard/MainWindow.xaml.cs
@@ -277,6 +277,11 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
await CheckAllConnectionsAsync();
+ /* Past startup init (MCP bound, services configured) — open the single-instance "exit for
+ upgrade" channel so a newer build can ask us to step aside cleanly
+ (#single-instance-upgrade-handoff). */
+ (Application.Current as App)?.EnableUpgradeHandoff();
+
_ = CheckForUpdatesOnStartupAsync();
}
diff --git a/Lite.Tests/SingleInstanceDecisionTests.cs b/Lite.Tests/SingleInstanceDecisionTests.cs
new file mode 100644
index 00000000..44893cee
--- /dev/null
+++ b/Lite.Tests/SingleInstanceDecisionTests.cs
@@ -0,0 +1,110 @@
+using System;
+using PerformanceMonitor.Ui;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// Unit coverage for the version-aware single-instance upgrade-handoff decision
+/// (plans/single-instance-upgrade-handoff.md). The decision is a pure function over already-measured
+/// inputs; the brittle Win32 measurement (Win32ProcessInspector) is exercised separately at runtime.
+///
+public class SingleInstanceDecisionTests
+{
+ private const int Medium = 0x2000;
+ private const int High = 0x3000;
+
+ private static Version V(string s) => Version.Parse(s);
+
+ [Fact]
+ public void OlderAtSameIntegrity_TakesOver()
+ {
+ var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, Medium, canElevateSameUser: false);
+ Assert.Equal(HandoffAction.TakeOver, action);
+ }
+
+ [Fact]
+ public void OlderAtLowerIntegrity_TakesOver()
+ {
+ // We're High, the old one is Medium — reachable.
+ var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), High, Medium, canElevateSameUser: false);
+ Assert.Equal(HandoffAction.TakeOver, action);
+ }
+
+ [Fact]
+ public void OlderButHigherIntegrity_WhenCanElevate_OffersRunas()
+ {
+ var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, High, canElevateSameUser: true);
+ Assert.Equal(HandoffAction.IntegrityErrorWithRunas, action);
+ }
+
+ [Fact]
+ public void OlderButHigherIntegrity_WhenCannotElevate_ManualOnly()
+ {
+ var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, High, canElevateSameUser: false);
+ Assert.Equal(HandoffAction.IntegrityErrorManualOnly, action);
+ }
+
+ [Fact]
+ public void OlderButIntegrityUnmeasurable_Surfaces()
+ {
+ // Couldn't read the other instance's IL → don't guess; surface (no false UAC).
+ var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, null, canElevateSameUser: true);
+ Assert.Equal(HandoffAction.Surface, action);
+ }
+
+ [Fact]
+ public void SameVersion_Surfaces_RegardlessOfIntegrity()
+ {
+ Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), V("3.0.0"), Medium, Medium, false));
+ // An already-running elevated same-version instance must NOT be misclassified as a mismatch.
+ Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), V("3.0.0"), Medium, High, true));
+ }
+
+ [Fact]
+ public void NewerRunningInstance_Surfaces()
+ {
+ // An older build launched over a newer one must not evict it.
+ var action = SingleInstanceDecision.Decide(V("3.0.0"), V("3.1.0"), Medium, Medium, canElevateSameUser: true);
+ Assert.Equal(HandoffAction.Surface, action);
+ }
+
+ [Fact]
+ public void UnreadableVersions_Surface()
+ {
+ Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(null, V("3.0.0"), Medium, Medium, false));
+ Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), null, Medium, Medium, false));
+ }
+
+ [Theory]
+ [InlineData("3.0.1", "3.0.1")]
+ [InlineData("3.0.1.4", "3.0.1.4")]
+ [InlineData("3.0.1-nightly.20260618", "3.0.1")] // pre-release suffix stripped
+ [InlineData("3.0.1+abc1234", "3.0.1")] // build metadata stripped
+ [InlineData(" 3.0.1 ", "3.0.1")] // trimmed
+ public void ParseProductVersion_ParsesNumericCore(string raw, string expected)
+ {
+ Assert.Equal(Version.Parse(expected), SingleInstanceDecision.ParseProductVersion(raw));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-a-version")]
+ [InlineData("-nightly")]
+ public void ParseProductVersion_ReturnsNullForUnparseable(string? raw)
+ {
+ Assert.Null(SingleInstanceDecision.ParseProductVersion(raw));
+ }
+
+ [Fact]
+ public void DriftedFileVersionButBumpedProductVersion_StillOlder()
+ {
+ // The C1 regression guard: compare ProductVersion (the bumped ), even if other
+ // version fields drift. Here the running build's resolved version is genuinely older.
+ var ours = SingleInstanceDecision.ParseProductVersion("3.1.0+ci");
+ var other = SingleInstanceDecision.ParseProductVersion("3.0.0+ci");
+ Assert.Equal(HandoffAction.TakeOver, SingleInstanceDecision.Decide(ours, other, Medium, Medium, false));
+ }
+}
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index 93af6088..70a3ce31 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -35,8 +35,12 @@ public partial class App : Application
private static extern void SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string appId);
private const string MutexName = "PerformanceMonitorLite_SingleInstance";
- private Mutex? _singleInstanceMutex;
- private bool _ownsMutex;
+ /* Version-aware single-instance + upgrade handoff (plans/single-instance-upgrade-handoff.md):
+ a newer build launched over an older tray-resident one closes it and takes over instead of
+ being handed back the stale in-memory version. The coordinator owns the mutex + the exit
+ listener for the life of the owning process. */
+ private const string ExitForUpgradeEventName = "PerformanceMonitorLite_ExitForUpgrade";
+ private SingleInstanceCoordinator? _instanceCoordinator;
/* Single-instance "surface the window" channel (#769, #1050). A second launch signals this named
event and exits; the owning instance restores its window through WPF's own Show() path
@@ -261,17 +265,25 @@ protected override void OnStartup(StartupEventArgs e)
{
SetCurrentProcessExplicitAppUserModelID("DarlingData.PerformanceMonitor.Lite");
- // Check for existing instance
- _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex);
-
- if (!_ownsMutex)
+ /* Single-instance with upgrade handoff. Runs synchronously, at the top of OnStartup before
+ base.OnStartup and any window/data init, so we only open the shared DuckDB / bind the MCP
+ port after any older instance has released them. A newer build closes an older tray-resident
+ one and takes over; a same/newer one just surfaces the existing instance (today's behavior);
+ an older-but-elevated one raises an actionable error. */
+ _instanceCoordinator = new SingleInstanceCoordinator(new SingleInstanceOptions
+ {
+ MutexName = MutexName,
+ ProcessName = "PerformanceMonitorLite",
+ ExitEventName = ExitForUpgradeEventName,
+ SurfaceRunningInstance = () => SingleInstanceSignal.TrySignal(ShowWindowEventName),
+ GracefulSelfExit = () => Dispatcher.BeginInvoke(new Action(Shutdown)),
+ Prompts = new MessageBoxHandoffPrompts("Performance Monitor Lite"),
+ AutoConfirm = Array.Exists(e.Args, a => string.Equals(a, HandoffArgs.AutoConfirm, StringComparison.OrdinalIgnoreCase)),
+ Log = msg => { try { AppLogger.Info("SingleInstance", msg); } catch { /* logger not yet initialized */ } },
+ });
+
+ if (!_instanceCoordinator.TryBecomeOwner())
{
- /* Ask the running instance to surface its window, then exit (#769). We signal a named
- event rather than poking its HWND with Win32 ShowWindow: the owning instance restores
- through WPF's own Show() path, which is the only thing that un-blanks a tray-hidden
- window (#1050). Best-effort — if the first instance is still mid-startup the channel
- may not exist yet, but it's already coming up visible anyway. */
- SingleInstanceSignal.TrySignal(ShowWindowEventName);
Shutdown();
return;
}
@@ -346,6 +358,13 @@ private void OnSurfaceWindowRequested()
Dispatcher.BeginInvoke(new Action(() => _mainWindow?.RestoreFromTray()));
}
+ ///
+ /// Opens the upgrade-handoff "exit" channel once startup is past its risky init (DuckDB ready).
+ /// Called by after initialization so a newer build won't signal/kill us
+ /// mid-init (#single-instance-upgrade-handoff). Safe to call more than once.
+ ///
+ public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff();
+
protected override void OnExit(ExitEventArgs e)
{
AppLogger.Info("App", "Shutting down");
@@ -354,11 +373,8 @@ protected override void OnExit(ExitEventArgs e)
AppLogger.Shutdown();
- if (_ownsMutex)
- {
- _singleInstanceMutex?.ReleaseMutex();
- }
- _singleInstanceMutex?.Dispose();
+ /* Releases the mutex + disposes the exit-for-upgrade listener. */
+ _instanceCoordinator?.Dispose();
base.OnExit(e);
}
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index d9b7778f..83b83f3d 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -259,6 +259,10 @@ seeding here (not just before the explicit RefreshOverviewAsync later) keeps a
await RefreshOverviewAsync();
StatusText.Text = "Ready - Collection active";
+ /* Now past the risky DuckDB init — open the single-instance "exit for upgrade" channel so a
+ newer build can ask us to step aside cleanly (#single-instance-upgrade-handoff). */
+ (Application.Current as App)?.EnableUpgradeHandoff();
+
_ = CheckForUpdatesOnStartupAsync();
}
catch (Exception ex)
diff --git a/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs b/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs
new file mode 100644
index 00000000..5df9eb8c
--- /dev/null
+++ b/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Windows;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// Shared WPF -based so both apps show the
+ /// same upgrade-handoff dialogs (parity), parameterized by the app's display name. Runs early in
+ /// App.OnStartup (before the main window exists); an ownerless MessageBox is used because a
+ /// freshly-launched process's dialog comes up foreground, and an off-screen owner would mis-place
+ /// the box. (Guaranteed-topmost is a possible refinement, not correctness.)
+ ///
+ public sealed class MessageBoxHandoffPrompts : IHandoffPrompts
+ {
+ private readonly string _appName;
+
+ public MessageBoxHandoffPrompts(string appName) => _appName = appName;
+
+ public bool ConfirmCloseAndContinue(string oldVersion, string newVersion) =>
+ MessageBox.Show(
+ $"A previous version ({oldVersion}) of {_appName} is still running.\n\n" +
+ $"Close it and continue with version {newVersion}?",
+ $"{_appName} — Update",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question) == MessageBoxResult.Yes;
+
+ public bool ConfirmRestartAsAdmin(string oldVersion) =>
+ MessageBox.Show(
+ $"A previous version ({oldVersion}) of {_appName} is running with administrator " +
+ "privileges and can't be closed automatically.\n\n" +
+ "Restart as administrator to continue the update?",
+ $"{_appName} — Update",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning) == MessageBoxResult.Yes;
+
+ public void ShowMustCloseElevatedManually(string oldVersion) =>
+ MessageBox.Show(
+ $"A previous version ({oldVersion}) of {_appName} is running with administrator " +
+ "privileges and can't be closed automatically.\n\n" +
+ $"Close it from its system tray icon, then reopen {_appName}.",
+ $"{_appName} — Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ }
+}
diff --git a/PerformanceMonitor.Ui/ProcessInspector.cs b/PerformanceMonitor.Ui/ProcessInspector.cs
new file mode 100644
index 00000000..5244b6d3
--- /dev/null
+++ b/PerformanceMonitor.Ui/ProcessInspector.cs
@@ -0,0 +1,299 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// Measures the facts the upgrade handoff needs about another running process — its release
+ /// version (read from the on-disk exe, which works cross-integrity for the same user) and its
+ /// mandatory-integrity level — plus our own integrity and whether we can elevate same-user.
+ /// Behind an interface so can be tested with a fake
+ /// and the brittle Win32 stays isolated. Every method fails CLOSED (returns null/false) so a
+ /// denied or unexpected call degrades to "surface", never a crash or a false elevation prompt.
+ ///
+ public interface IProcessInspector
+ {
+ /// Release version (ProductVersion of the on-disk exe) for the given pid, or null if unreadable.
+ Version? GetReleaseVersion(int pid);
+
+ /// Mandatory-integrity RID (e.g. 0x3000 = High) for the given pid, or null if it couldn't be measured.
+ int? GetIntegrityLevel(int pid);
+
+ /// This process's mandatory-integrity RID; falls back to Medium (0x2000) if it can't be read.
+ int CurrentIntegrityLevel();
+
+ /// True if the current user could elevate in place (already elevated, split-token admin, or a UAC-off / built-in admin).
+ bool CanElevateSameUser();
+ }
+
+ /// Real Win32-backed .
+ public sealed class Win32ProcessInspector : IProcessInspector
+ {
+ public const int IntegrityMedium = 0x2000;
+ public const int IntegrityHigh = 0x3000;
+
+ public Version? GetReleaseVersion(int pid)
+ {
+ try
+ {
+ var path = QueryImagePath(pid);
+ if (string.IsNullOrEmpty(path))
+ {
+ return null;
+ }
+
+ /* Read the version from the on-disk file (world-readable), NOT process memory —
+ FileVersionInfo here, not Process.MainModule, so an elevated same-user instance
+ is still readable from a non-elevated launcher. */
+ var info = FileVersionInfo.GetVersionInfo(path);
+ return SingleInstanceDecision.ParseProductVersion(info.ProductVersion)
+ ?? SingleInstanceDecision.ParseProductVersion(info.FileVersion);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public int? GetIntegrityLevel(int pid)
+ {
+ IntPtr process = IntPtr.Zero;
+ try
+ {
+ process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, (uint)pid);
+ if (process == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ return ReadIntegrityLevel(process);
+ }
+ catch
+ {
+ return null;
+ }
+ finally
+ {
+ if (process != IntPtr.Zero)
+ {
+ CloseHandle(process);
+ }
+ }
+ }
+
+ public int CurrentIntegrityLevel()
+ {
+ try
+ {
+ var level = ReadIntegrityLevel(GetCurrentProcess());
+ return level ?? IntegrityMedium;
+ }
+ catch
+ {
+ return IntegrityMedium;
+ }
+ }
+
+ public bool CanElevateSameUser()
+ {
+ try
+ {
+ /* Already running elevated → we can act as admin. */
+ using var identity = WindowsIdentity.GetCurrent();
+ if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
+ {
+ return true;
+ }
+
+ /* Not elevated: a split-token admin can elevate in place; a true standard user cannot
+ (runas would require *different* admin credentials → a different user/profile). */
+ var type = GetElevationType();
+ return type == TokenElevationType.Limited;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string? QueryImagePath(int pid)
+ {
+ IntPtr process = IntPtr.Zero;
+ try
+ {
+ process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, (uint)pid);
+ if (process == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ var buffer = new char[1024];
+ uint size = (uint)buffer.Length;
+ return QueryFullProcessImageName(process, 0, buffer, ref size) ? new string(buffer, 0, (int)size) : null;
+ }
+ finally
+ {
+ if (process != IntPtr.Zero)
+ {
+ CloseHandle(process);
+ }
+ }
+ }
+
+ private static int? ReadIntegrityLevel(IntPtr process)
+ {
+ IntPtr token = IntPtr.Zero;
+ IntPtr info = IntPtr.Zero;
+ try
+ {
+ if (!OpenProcessToken(process, TOKEN_QUERY, out token))
+ {
+ return null;
+ }
+
+ GetTokenInformation(token, TokenInformationClass.TokenIntegrityLevel, IntPtr.Zero, 0, out uint needed);
+ if (needed == 0)
+ {
+ return null;
+ }
+
+ info = Marshal.AllocHGlobal((int)needed);
+ if (!GetTokenInformation(token, TokenInformationClass.TokenIntegrityLevel, info, needed, out _))
+ {
+ return null;
+ }
+
+ var label = Marshal.PtrToStructure(info);
+ var sid = label.Label.Sid;
+ int subAuthorityCount = Marshal.ReadByte(GetSidSubAuthorityCount(sid));
+ if (subAuthorityCount == 0)
+ {
+ return null;
+ }
+
+ IntPtr ridPtr = GetSidSubAuthority(sid, (uint)(subAuthorityCount - 1));
+ return Marshal.ReadInt32(ridPtr);
+ }
+ catch
+ {
+ return null;
+ }
+ finally
+ {
+ if (info != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(info);
+ }
+ if (token != IntPtr.Zero)
+ {
+ CloseHandle(token);
+ }
+ }
+ }
+
+ private static TokenElevationType GetElevationType()
+ {
+ IntPtr token = IntPtr.Zero;
+ try
+ {
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, out token))
+ {
+ return TokenElevationType.Default;
+ }
+
+ uint size = sizeof(int);
+ IntPtr buffer = Marshal.AllocHGlobal((int)size);
+ try
+ {
+ if (!GetTokenInformation(token, TokenInformationClass.TokenElevationType, buffer, size, out _))
+ {
+ return TokenElevationType.Default;
+ }
+ return (TokenElevationType)Marshal.ReadInt32(buffer);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+ catch
+ {
+ return TokenElevationType.Default;
+ }
+ finally
+ {
+ if (token != IntPtr.Zero)
+ {
+ CloseHandle(token);
+ }
+ }
+ }
+
+ private enum TokenElevationType
+ {
+ Default = 1,
+ Full = 2,
+ Limited = 3,
+ }
+
+ private enum TokenInformationClass
+ {
+ TokenElevationType = 18,
+ TokenIntegrityLevel = 25,
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SID_AND_ATTRIBUTES
+ {
+ public IntPtr Sid;
+ public uint Attributes;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct TOKEN_MANDATORY_LABEL
+ {
+ public SID_AND_ATTRIBUTES Label;
+ }
+
+ private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
+ private const uint TOKEN_QUERY = 0x0008;
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentProcess();
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QueryFullProcessImageName(IntPtr hProcess, uint flags, char[] exeName, ref uint size);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetTokenInformation(IntPtr tokenHandle, TokenInformationClass tokenInformationClass, IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern IntPtr GetSidSubAuthorityCount(IntPtr sid);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern IntPtr GetSidSubAuthority(IntPtr sid, uint subAuthorityIndex);
+ }
+}
diff --git a/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs b/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs
new file mode 100644
index 00000000..880aabd8
--- /dev/null
+++ b/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs
@@ -0,0 +1,371 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+
+namespace PerformanceMonitor.Ui
+{
+ /// Command-line flag the elevated relaunch carries so it skips the "close it?" prompt.
+ public static class HandoffArgs
+ {
+ public const string AutoConfirm = "--upgrade-takeover";
+ }
+
+ /// App-supplied dialogs for the handoff. Implemented per app (WPF MessageBox), kept out of
+ /// the coordinator so the orchestration stays testable and the UI stays in the app.
+ public interface IHandoffPrompts
+ {
+ /// "A previous version is still running. Close it and continue?" — true = proceed.
+ bool ConfirmCloseAndContinue(string oldVersion, string newVersion);
+
+ /// "…is running as administrator. Restart as administrator to continue?" — true = elevate.
+ bool ConfirmRestartAsAdmin(string oldVersion);
+
+ /// Info-only: an elevated previous version must be closed from its tray icon.
+ void ShowMustCloseElevatedManually(string oldVersion);
+ }
+
+ /// Per-app configuration for .
+ public sealed class SingleInstanceOptions
+ {
+ /// Constant per-app mutex name (e.g. PerformanceMonitorLite_SingleInstance).
+ public string MutexName { get; set; } = string.Empty;
+
+ /// Process name without extension (e.g. PerformanceMonitorLite).
+ public string ProcessName { get; set; } = string.Empty;
+
+ /// Shared named event the owning instance listens on for an "exit for upgrade" request.
+ public string ExitEventName { get; set; } = string.Empty;
+
+ /// Run in the NEW instance to tell the running instance to surface (Lite: signal the show
+ /// event; Dashboard: broadcast WM_SHOWMONITOR).
+ public Action SurfaceRunningInstance { get; set; } = static () => { };
+
+ /// Run in the OWNING instance when an exit-for-upgrade request arrives — the app's real
+ /// shutdown path (Lite: Application.Shutdown(); Dashboard: MainWindow.ExitApplication()).
+ public Action GracefulSelfExit { get; set; } = static () => { };
+
+ /// App dialogs.
+ public IHandoffPrompts Prompts { get; set; } = null!;
+
+ /// True when launched by an elevated relaunch () — skip the close prompt.
+ public bool AutoConfirm { get; set; }
+
+ /// Injectable for tests; defaults to .
+ public IProcessInspector? Inspector { get; set; }
+
+ /// Optional diagnostic logging.
+ public Action? Log { get; set; }
+ }
+
+ ///
+ /// Version-aware single-instance enforcement with upgrade handoff. Replaces the old inline
+ /// "mutex held → surface + exit" block: when a NEWER build launches over an OLDER running one
+ /// (the post-upgrade case), it closes the old one and takes over instead of being handed back
+ /// the stale in-memory version. See plans/single-instance-upgrade-handoff.md.
+ ///
+ /// Lifetime: the owning process keeps the instance (it holds the mutex + the exit listener) and
+ /// disposes it on exit.
+ ///
+ public sealed class SingleInstanceCoordinator : IDisposable
+ {
+ private const int GracefulWaitWithListenerMs = 20000; // old instance's own clean shutdown can take ~10s+
+ private const int GracefulWaitNoListenerMs = 3000; // no listener yet → it may be mid-startup
+ private const int PostKillWaitMs = 3000;
+ private const int MutexAcquireTimeoutMs = 5000;
+
+ private readonly SingleInstanceOptions _options;
+ private readonly IProcessInspector _inspector;
+ private Mutex? _mutex;
+ private bool _ownsMutex;
+ private SingleInstanceSignal? _exitSignal;
+ private bool _disposed;
+
+ public SingleInstanceCoordinator(SingleInstanceOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ if (string.IsNullOrEmpty(options.MutexName) || string.IsNullOrEmpty(options.ProcessName) || string.IsNullOrEmpty(options.ExitEventName))
+ throw new ArgumentException("MutexName, ProcessName, and ExitEventName are required.", nameof(options));
+ if (options.Prompts is null)
+ throw new ArgumentException("Prompts is required.", nameof(options));
+ _inspector = options.Inspector ?? new Win32ProcessInspector();
+ }
+
+ ///
+ /// True if this instance should proceed with startup (it now owns the single-instance slot).
+ /// False if the caller should Shutdown() immediately (it surfaced an existing instance,
+ /// raised an integrity error, declined, or relaunched elevated).
+ ///
+ public bool TryBecomeOwner()
+ {
+ try
+ {
+ _mutex = new Mutex(true, _options.MutexName, out var createdNew);
+ if (createdNew)
+ {
+ BecomeOwner();
+ return true;
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ /* The named mutex was created by a higher-integrity (elevated) instance; a non-elevated
+ process is denied write access to it (mandatory NO_WRITE_UP). That IS the elevated-old
+ case — fall through to the handoff, which measures the integrity mismatch and raises
+ the actionable error (+ "Restart as administrator"). The handoff null-guards the
+ absent mutex; after elevation the equal-integrity relaunch acquires it normally. */
+ _mutex = null;
+ }
+ catch (System.IO.IOException)
+ {
+ _mutex = null;
+ }
+
+ /* Another instance holds the mutex — decide whether to surface it (today's behavior) or
+ take over (it's a stale older build from before an upgrade). */
+ return Handoff();
+ }
+
+ private bool Handoff()
+ {
+ var others = FindOtherInstances();
+ if (others.Count == 0)
+ {
+ /* Mutex held but no peer process found — a race or an abandoned mutex. Try to grab it. */
+ return TryAcquireAfterRelease() || SurfaceAndExit();
+ }
+
+ try
+ {
+ var ourVersion = CurrentReleaseVersion();
+ var ourIntegrity = _inspector.CurrentIntegrityLevel();
+ var otherVersion = _inspector.GetReleaseVersion(others[0].Id);
+ var otherIntegrity = _inspector.GetIntegrityLevel(others[0].Id);
+ var canElevate = _inspector.CanElevateSameUser();
+
+ var action = SingleInstanceDecision.Decide(ourVersion, otherVersion, ourIntegrity, otherIntegrity, canElevate);
+ _options.Log?.Invoke($"Handoff: ours={ourVersion} other={otherVersion} ourIL={ourIntegrity} otherIL={otherIntegrity} canElevate={canElevate} -> {action}");
+
+ var oldText = otherVersion?.ToString() ?? "unknown";
+ var newText = ourVersion?.ToString() ?? "unknown";
+
+ switch (action)
+ {
+ case HandoffAction.TakeOver:
+ if (!_options.AutoConfirm && !_options.Prompts.ConfirmCloseAndContinue(oldText, newText))
+ return SurfaceAndExit();
+ return TakeOverFrom(others) || SurfaceAndExit();
+
+ case HandoffAction.IntegrityErrorWithRunas:
+ if (_options.Prompts.ConfirmRestartAsAdmin(oldText))
+ RelaunchElevated();
+ return false; // either relaunched, or declined — exit without surfacing
+
+ case HandoffAction.IntegrityErrorManualOnly:
+ _options.Prompts.ShowMustCloseElevatedManually(oldText);
+ return false;
+
+ case HandoffAction.Surface:
+ default:
+ return SurfaceAndExit();
+ }
+ }
+ finally
+ {
+ foreach (var proc in others)
+ {
+ proc.Dispose();
+ }
+ }
+ }
+
+ private bool TakeOverFrom(List others)
+ {
+ /* Ask the old instance(s) to shut down gracefully (it runs its real exit path: flush DuckDB,
+ dispose tray, release the mutex). The shared exit event broadcasts to whoever is listening. */
+ var delivered = SingleInstanceSignal.TrySignal(_options.ExitEventName);
+ var budget = delivered ? GracefulWaitWithListenerMs : GracefulWaitNoListenerMs;
+
+ var stillRunning = WaitForExit(others, budget);
+ if (stillRunning.Count > 0)
+ {
+ if (!delivered)
+ {
+ /* No listener existed → the old instance may be mid-startup (e.g. building the DuckDB
+ schema). Killing then is riskier than a stale alert; bail to surface instead. */
+ return false;
+ }
+
+ /* It had a listener (past startup) but didn't exit in time — force-kill as a last resort. */
+ foreach (var proc in stillRunning)
+ {
+ TryKill(proc);
+ }
+ WaitForExit(stillRunning, PostKillWaitMs);
+ }
+
+ return TryAcquireAfterRelease();
+ }
+
+ private bool TryAcquireAfterRelease()
+ {
+ if (_mutex is null)
+ return false;
+ try
+ {
+ if (_mutex.WaitOne(MutexAcquireTimeoutMs))
+ {
+ BecomeOwner();
+ return true;
+ }
+ }
+ catch (AbandonedMutexException)
+ {
+ /* Previous owner died without releasing — we now hold it. */
+ BecomeOwner();
+ return true;
+ }
+ return false;
+ }
+
+ private void BecomeOwner()
+ {
+ _ownsMutex = true;
+ }
+
+ ///
+ /// Opens the "exit for upgrade" channel — the app calls this once it is past its risky startup
+ /// (DuckDB initialized, MCP bound, etc.). Deferring it here (rather than in )
+ /// preserves the safety rule that a newer build won't disturb an instance that is still
+ /// initializing: while no listener exists, a launching newer build sees TrySignal == false
+ /// and surfaces instead of signaling/killing us mid-init. No-op if we don't own the slot or the
+ /// channel is already open. Idempotent and thread-safe to call from the UI thread.
+ ///
+ public void EnableUpgradeHandoff()
+ {
+ if (!_ownsMutex || _exitSignal is not null || _disposed)
+ return;
+ /* Listen for a future newer build asking us to step aside. */
+ _exitSignal = new SingleInstanceSignal(_options.ExitEventName, _options.GracefulSelfExit);
+ }
+
+ private bool SurfaceAndExit()
+ {
+ try { _options.SurfaceRunningInstance(); }
+ catch (Exception ex) { _options.Log?.Invoke($"Surface failed: {ex.Message}"); }
+ return false;
+ }
+
+ private void RelaunchElevated()
+ {
+ try
+ {
+ var exe = Environment.ProcessPath;
+ if (string.IsNullOrEmpty(exe))
+ return;
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = exe,
+ Arguments = HandoffArgs.AutoConfirm,
+ UseShellExecute = true,
+ Verb = "runas",
+ });
+ }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) // ERROR_CANCELLED — user dismissed UAC
+ {
+ _options.Log?.Invoke("Elevation cancelled by user.");
+ }
+ catch (Exception ex)
+ {
+ _options.Log?.Invoke($"Elevation failed: {ex.Message}");
+ }
+ }
+
+ private List FindOtherInstances()
+ {
+ var self = Environment.ProcessId;
+ var result = new List();
+ try
+ {
+ foreach (var proc in Process.GetProcessesByName(_options.ProcessName))
+ {
+ if (proc.Id == self)
+ {
+ proc.Dispose();
+ continue;
+ }
+ result.Add(proc);
+ }
+ }
+ catch (Exception ex)
+ {
+ _options.Log?.Invoke($"Enumerate instances failed: {ex.Message}");
+ }
+ return result;
+ }
+
+ private static List WaitForExit(List procs, int totalBudgetMs)
+ {
+ var deadline = Environment.TickCount64 + totalBudgetMs;
+ var stillRunning = new List();
+ foreach (var proc in procs)
+ {
+ var remaining = (int)Math.Max(0, deadline - Environment.TickCount64);
+ try
+ {
+ if (!proc.WaitForExit(remaining))
+ stillRunning.Add(proc);
+ }
+ catch
+ {
+ /* Process already gone / inaccessible — treat as exited. */
+ }
+ }
+ return stillRunning;
+ }
+
+ private void TryKill(Process proc)
+ {
+ try { proc.Kill(); }
+ catch (Exception ex) { _options.Log?.Invoke($"Kill failed: {ex.Message}"); }
+ }
+
+ private static Version? CurrentReleaseVersion()
+ {
+ var informational = Assembly.GetEntryAssembly()
+ ?.GetCustomAttribute()
+ ?.InformationalVersion;
+ return SingleInstanceDecision.ParseProductVersion(informational)
+ ?? Assembly.GetEntryAssembly()?.GetName().Version;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ _exitSignal?.Dispose();
+ if (_mutex is not null)
+ {
+ if (_ownsMutex)
+ {
+ try { _mutex.ReleaseMutex(); } catch { /* not held / abandoned */ }
+ }
+ _mutex.Dispose();
+ }
+ }
+ }
+}
diff --git a/PerformanceMonitor.Ui/SingleInstanceDecision.cs b/PerformanceMonitor.Ui/SingleInstanceDecision.cs
new file mode 100644
index 00000000..bc7b05f2
--- /dev/null
+++ b/PerformanceMonitor.Ui/SingleInstanceDecision.cs
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// What a launching instance should do when it finds the single-instance mutex already held
+ /// (version-aware upgrade handoff). Pure decision over already-measured inputs — no Win32, no
+ /// processes — so it is fully unit-testable; the messy measurement lives in
+ /// and the orchestration in .
+ ///
+ public enum HandoffAction
+ {
+ /// The running instance is an older build at an integrity level we can reach — close it and take over.
+ TakeOver,
+
+ /// Genuine "already running" (same/newer build), or we can't determine enough to safely act — surface the running instance and exit.
+ Surface,
+
+ /// Running instance is older but at a higher integrity level we can't signal/kill, and we can elevate same-user — offer "Restart as administrator".
+ IntegrityErrorWithRunas,
+
+ /// Running instance is older but at a higher integrity level we can't signal/kill, and we cannot elevate same-user — show a manual-close message.
+ IntegrityErrorManualOnly,
+ }
+
+ ///
+ /// The version-aware single-instance handoff decision (#single-instance-upgrade-handoff plan).
+ ///
+ public static class SingleInstanceDecision
+ {
+ /// This (launching) build's release version, or null if it couldn't be resolved.
+ /// The running instance's release version read from its on-disk exe, or null if unreadable.
+ /// This process's mandatory-integrity RID (e.g. 0x3000 = High).
+ /// The running instance's integrity RID, or null if it couldn't be measured.
+ /// Whether the current user could elevate in place (split-token admin / already elevated).
+ public static HandoffAction Decide(
+ Version? ourVersion,
+ Version? otherVersion,
+ int ourIntegrityLevel,
+ int? otherIntegrityLevel,
+ bool canElevateSameUser)
+ {
+ /* Can't read either version → can't tell if this is an upgrade → behave as today (surface). */
+ if (ourVersion is null || otherVersion is null)
+ {
+ return HandoffAction.Surface;
+ }
+
+ /* Same or newer running instance → genuine "already running" (incl. a plain double-launch),
+ or an older build trying to evict a newer one. Never take over. */
+ if (otherVersion.CompareTo(ourVersion) >= 0)
+ {
+ return HandoffAction.Surface;
+ }
+
+ /* The running instance is OLDER — a real takeover is warranted. Can we reach it?
+ We need its integrity level to know whether our signal/kill can land. If we couldn't
+ measure it (e.g. token read denied), don't guess — fall back to surface. */
+ if (otherIntegrityLevel is null)
+ {
+ return HandoffAction.Surface;
+ }
+
+ /* Older and at an integrity level at or below ours → we can signal/kill it → take over. */
+ if (otherIntegrityLevel.Value <= ourIntegrityLevel)
+ {
+ return HandoffAction.TakeOver;
+ }
+
+ /* Older but higher integrity than us → our exit signal and Kill() are both blocked.
+ Surface a clear error; offer elevation only if it would stay same-user. */
+ return canElevateSameUser
+ ? HandoffAction.IntegrityErrorWithRunas
+ : HandoffAction.IntegrityErrorManualOnly;
+ }
+
+ ///
+ /// Parses a ProductVersion / InformationalVersion string to its numeric core for comparison.
+ /// Strips any SemVer pre-release (-rc1) or build (+sha) suffix and parses the
+ /// leading X.Y[.Z[.W]] with — deliberately dependency-free.
+ /// Consequence: two builds that share the same numeric <Version> (e.g. two nightlies,
+ /// or rc1→rc2) compare equal → "surface", which is also the right call for a normal double-launch.
+ /// Real release upgrades bump <Version>, so the upgrade case is covered. Returns null
+ /// if there is no parseable numeric core.
+ ///
+ public static Version? ParseProductVersion(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var core = raw.Trim();
+
+ var plus = core.IndexOf('+');
+ if (plus >= 0)
+ {
+ core = core.Substring(0, plus);
+ }
+
+ var dash = core.IndexOf('-');
+ if (dash >= 0)
+ {
+ core = core.Substring(0, dash);
+ }
+
+ return Version.TryParse(core, out var version) ? version : null;
+ }
+ }
+}