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; + } + } +}