Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions Dashboard/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -73,6 +92,13 @@ protected override void OnStartup(StartupEventArgs e)
mainWindow.Show();
}

/// <summary>
/// Opens the upgrade-handoff "exit" channel once startup is past its risky init. Called by
/// <see cref="MainWindow"/> after initialization so a newer build won't signal/kill us mid-init
/// (#single-instance-upgrade-handoff). Safe to call more than once.
/// </summary>
public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff();

protected override void OnExit(ExitEventArgs e)
{
Logger.Info($"=== Application Exiting (Exit Code: {e.ApplicationExitCode}) ===");
Expand All @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions Dashboard/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
110 changes: 110 additions & 0 deletions Lite.Tests/SingleInstanceDecisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System;
using PerformanceMonitor.Ui;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// 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.
/// </summary>
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 <Version>), 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));
}
}
50 changes: 33 additions & 17 deletions Lite/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -346,6 +358,13 @@ private void OnSurfaceWindowRequested()
Dispatcher.BeginInvoke(new Action(() => _mainWindow?.RestoreFromTray()));
}

/// <summary>
/// Opens the upgrade-handoff "exit" channel once startup is past its risky init (DuckDB ready).
/// Called by <see cref="MainWindow"/> after initialization so a newer build won't signal/kill us
/// mid-init (#single-instance-upgrade-handoff). Safe to call more than once.
/// </summary>
public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff();

protected override void OnExit(ExitEventArgs e)
{
AppLogger.Info("App", "Shutting down");
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions Lite/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Shared WPF <see cref="MessageBox"/>-based <see cref="IHandoffPrompts"/> so both apps show the
/// same upgrade-handoff dialogs (parity), parameterized by the app's display name. Runs early in
/// <c>App.OnStartup</c> (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.)
/// </summary>
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);
}
}
Loading
Loading