Skip to content

Commit 176b931

Browse files
Merge pull request erikdarlingdata#1053 from erikdarlingdata/feature/1050-sleep-wake-render
Fix erikdarlingdata#1050: UI blank/missing after sleep-wake (Lite + Dashboard)
2 parents db134d1 + 94a6feb commit 176b931

9 files changed

Lines changed: 199 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Lite and Dashboard UI no longer goes blank or disappears after sleep/wake** ([#1050]) — closing a laptop lid (or locking the screen) and then resuming could leave the app running with no usable window: notifications kept firing but the window was gone from the desktop and taskbar, and relaunching showed an empty window until a full exit/restart. Two causes, both fixed. (1) WPF's GPU render thread can lose its rendering surface across a sleep/wake or RDP reconnect and never recover, leaving a live-but-blank window; both apps now use software rendering (`RenderOptions.ProcessRenderMode = SoftwareOnly`) to remove the GPU dependency — charts are unaffected because ScottPlot already renders via SkiaSharp. (2) When Windows turned the sleep-driven minimize into a hidden window, the minimize-to-tray logic left it hidden with no automatic way back; a new shared resume guard now restores the window from the tray on resume/unlock if it was visible beforehand (a window the user deliberately sent to the tray is left alone)
1213
- **"Silence All Alerts" now suppresses email too** ([#1035]) — right-clicking a monitored instance and choosing *Silence All Alerts* hid tray notifications and Alerts-tab badges, but two email paths ignored the silenced state and kept sending: connection up/down emails (*Server Unreachable* / *Server Restored*) and analysis-finding emails (the narrative findings from the analysis engine, which include CPU/memory/blocking stories). Only the threshold-alert path (High CPU, blocking, deadlocks, etc.) honored silencing. Both gaps are closed — a silenced server now produces no tray, email, or alert-history row from any path. The analysis path was the likely source of the reporter's "High CPU" email, since the threshold-based High CPU alert was already suppressed. The shared `AnalysisNotificationService` (used by Lite too) gains an optional per-server silence predicate; Lite has no silencing feature and passes none
1314
- **Dashboard time labels are now consistently 24-hour** ([#1012]) — the time-range header at the top of each tab (e.g. *"Original: May 28, 11:30 PM – May 29, 1:30 AM (PST)"*) and the Query Performance heatmap x-axis tick labels used `h:mm tt`, while every other timestamp in the app (footer "Last refresh", DataGrid columns, slicer, tooltips, logs) already used 24-hour `HH:mm`/`HH:mm:ss`. The AM/PM marker was also being truncated in the column shown by the reporter. Normalized the four outliers to `HH:mm` to match the rest of the app. The Lite heatmap had the same `h:mm tt` straggler — fixed alongside
1415
- **Lite UI no longer freezes during archival** ([#979]) — archival held DuckDB's exclusive write lock across the entire export-to-Parquet step, blocking every UI query (tab switches showed the spinning wheel, worse with more monitored servers). Export-to-Parquet only reads the database, so it now runs under a shared read lock concurrently with the UI; only the brief `DELETE` takes the exclusive write lock
@@ -39,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3940
[#981]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/981
4041
[#1012]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1012
4142
[#1035]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1035
43+
[#1050]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1050
4244

4345
## [2.11.0] - 2026-05-19
4446

Dashboard/App.xaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ protected override void OnStartup(StartupEventArgs e)
4242

4343
base.OnStartup(e);
4444

45+
// #1050: WPF's GPU render thread can zombie its surface across sleep/wake or RDP, leaving a
46+
// live-but-blank window. Software rendering removes the GPU dependency entirely. Charts are
47+
// unaffected — ScottPlot renders via SkiaSharp (CPU) into a bitmap, not WPF's GPU path.
48+
System.Windows.Media.RenderOptions.ProcessRenderMode =
49+
System.Windows.Interop.RenderMode.SoftwareOnly;
50+
4551
// Use the user's locale for date/time formatting in WPF bindings (issue #459)
4652
FrameworkElement.LanguageProperty.OverrideMetadata(
4753
typeof(FrameworkElement),

Dashboard/MainWindow.xaml.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public partial class MainWindow : Window
4444
private readonly DispatcherTimer _displayRefreshTimer;
4545
private readonly DispatcherTimer _connectionStatusTimer;
4646
private NotificationService? _notificationService;
47+
private WindowResumeGuard? _resumeGuard;
4748
private readonly AlertStateService _alertStateService;
4849
private readonly MuteRuleService _muteRuleService;
4950
private readonly Dictionary<string, bool> _previousConnectionStates;
@@ -349,6 +350,10 @@ private void InitializeNotificationService()
349350
{
350351
_notificationService = new NotificationService(this, _preferencesService);
351352
_notificationService.Initialize();
353+
354+
/* #1050: restore the window from the tray on resume/unlock if a sleep- or lock-driven
355+
minimize hid it. ??= so a repeated Loaded can't double-subscribe (static SystemEvents). */
356+
_resumeGuard ??= new WindowResumeGuard(this, _notificationService.ShowMainWindow);
352357
}
353358

354359
private void MainWindow_StateChanged(object? sender, EventArgs e)
@@ -388,7 +393,9 @@ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEven
388393
// Save alert history to disk
389394
_alertHistoryStore?.SaveAlertLog();
390395

391-
// Clean up notification service
396+
// Clean up notification service (real-close path only — the X-button minimize-to-tray
397+
// branch above returns early, so the resume guard stays alive while the app runs)
398+
_resumeGuard?.Dispose();
392399
_notificationService?.Dispose();
393400
}
394401

Dashboard/Services/NotificationService.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,10 @@ public void ShowConnectionRestoredNotification(string serverName)
211211
NotificationType.Success);
212212
}
213213

214-
private void ShowMainWindow()
214+
/// <summary>
215+
/// Restores the main window from the tray. Also used as the #1050 resume-restore callback.
216+
/// </summary>
217+
internal void ShowMainWindow()
215218
{
216219
_mainWindow.Show();
217220
_mainWindow.WindowState = WindowState.Normal;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright (c) 2026 Erik Darling, Darling Data LLC
3+
*
4+
* This file is part of the SQL Server Performance Monitor Lite.
5+
*
6+
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
7+
*/
8+
9+
using System.Windows;
10+
using PerformanceMonitor.Ui;
11+
using Xunit;
12+
13+
namespace PerformanceMonitorLite.Tests;
14+
15+
/// <summary>
16+
/// Truth-table coverage for <see cref="WindowResumeGuard.ShouldRestore"/> (#1050).
17+
/// The guard restores a window only when it was visible before a sleep/lock transition
18+
/// but is now hidden or minimized — and must leave a window the user deliberately sent
19+
/// to the tray (wasVisibleBeforeSuspend == false) alone, so resume never pops it back out.
20+
/// </summary>
21+
public class WindowResumeGuardTests
22+
{
23+
[Theory]
24+
/* Was visible before the transition... */
25+
[InlineData(true, false, WindowState.Minimized, true)] // now hidden (Hide()) -> restore (the bug)
26+
[InlineData(true, false, WindowState.Normal, true)] // now hidden, state stale -> restore
27+
[InlineData(true, true, WindowState.Minimized, true)] // now minimized-but-shown -> restore
28+
[InlineData(true, true, WindowState.Normal, false)] // still up and normal -> nothing to do
29+
[InlineData(true, true, WindowState.Maximized, false)] // still up and maximized -> nothing to do
30+
/* Was NOT visible before (user had it in the tray)... */
31+
[InlineData(false, false, WindowState.Minimized, false)] // leave tray-hidden window alone
32+
[InlineData(false, false, WindowState.Normal, false)]
33+
[InlineData(false, true, WindowState.Normal, false)]
34+
public void ShouldRestore_MatchesTruthTable(
35+
bool wasVisibleBeforeSuspend,
36+
bool isVisibleNow,
37+
WindowState state,
38+
bool expected)
39+
{
40+
Assert.Equal(expected, WindowResumeGuard.ShouldRestore(wasVisibleBeforeSuspend, isVisibleNow, state));
41+
}
42+
}

Lite/App.xaml.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ protected override void OnStartup(StartupEventArgs e)
270270

271271
base.OnStartup(e);
272272

273+
// #1050: WPF's GPU render thread can zombie its surface across sleep/wake or RDP, leaving a
274+
// live-but-blank window. Software rendering removes the GPU dependency entirely. Charts are
275+
// unaffected — ScottPlot renders via SkiaSharp (CPU) into a bitmap, not WPF's GPU path.
276+
System.Windows.Media.RenderOptions.ProcessRenderMode =
277+
System.Windows.Interop.RenderMode.SoftwareOnly;
278+
273279
// Initialize paths — store data in %LOCALAPPDATA% so Velopack updates
274280
// can replace the app directory without losing data
275281
var appDataRoot = Path.Combine(

Lite/MainWindow.xaml.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
using PerformanceMonitorLite.Services;
2626
using PerformanceMonitorLite.Windows;
2727
using PerformanceMonitor.Common;
28+
using PerformanceMonitor.Ui;
2829

2930
namespace PerformanceMonitorLite;
3031

@@ -38,6 +39,7 @@ public partial class MainWindow : Window
3839
private CollectionBackgroundService? _backgroundService;
3940
private CancellationTokenSource? _backgroundCts;
4041
private SystemTrayService? _trayService;
42+
private WindowResumeGuard? _resumeGuard;
4143
private readonly Dictionary<string, TabItem> _openServerTabs = new();
4244
private readonly Dictionary<string, (Action<int, int, DateTime?> AlertCounts, Action<int> ApplyTimeRange, Func<Task> ManualRefresh)> _tabEventHandlers = new();
4345
private readonly Dictionary<string, bool> _previousConnectionStates = new();
@@ -150,6 +152,10 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
150152
_trayService = new SystemTrayService(this, _backgroundService);
151153
_trayService.Initialize();
152154

155+
/* #1050: restore the window from the tray on resume/unlock if a sleep- or lock-driven
156+
minimize hid it. ??= so a repeated Loaded can't double-subscribe (static SystemEvents). */
157+
_resumeGuard ??= new WindowResumeGuard(this, _trayService.ShowMainWindow);
158+
153159
// Initialize data service for overview
154160
_dataService = new LocalDataService(_databaseInitializer);
155161

@@ -238,6 +244,7 @@ private async Task CheckForUpdatesOnStartupAsync()
238244
private async void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
239245
{
240246
// Dispose system tray
247+
_resumeGuard?.Dispose();
241248
_trayService?.Dispose();
242249

243250
// Stop background collection with timeout

Lite/Services/SystemTrayService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,11 @@ private void MainWindow_StateChanged(object? sender, EventArgs e)
121121
}
122122
}
123123

124-
private void ShowMainWindow()
124+
/// <summary>
125+
/// Restores the main window from the tray. Also used as the #1050 resume-restore callback,
126+
/// so it must set ShowInTaskbar = true (the tray-hide path only calls Hide()).
127+
/// </summary>
128+
internal void ShowMainWindow()
125129
{
126130
_mainWindow.Show();
127131
_mainWindow.ShowInTaskbar = true;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright (c) 2026 Erik Darling, Darling Data LLC
3+
*
4+
* This file is part of the SQL Server Performance Monitor.
5+
*
6+
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
7+
*/
8+
9+
using System;
10+
using System.Windows;
11+
using Microsoft.Win32;
12+
13+
namespace PerformanceMonitor.Ui
14+
{
15+
/// <summary>
16+
/// Restores a window that was auto-hidden to the system tray when Windows turned a
17+
/// sleep- or lock-driven minimize into a hidden window (#1050). On resume from sleep or
18+
/// on session unlock, if the window was visible before the transition but is now hidden
19+
/// or minimized, the supplied <paramref name="restoreToForeground"/> action is invoked
20+
/// on the window's UI thread.
21+
///
22+
/// This guard does NO rendering work. The companion "blank window after resume" symptom
23+
/// is handled separately by disabling hardware acceleration
24+
/// (<c>RenderOptions.ProcessRenderMode = SoftwareOnly</c>) at application startup.
25+
///
26+
/// It subscribes to the static <see cref="SystemEvents"/> events, which hold strong
27+
/// references to their handlers and raise on a dedicated (non-UI) thread. Callers MUST
28+
/// <see cref="Dispose"/> the guard when the window really closes, or it leaks the window
29+
/// and fires against a destroyed HWND. Construct it once per window (e.g. guard the
30+
/// construction site against a double <c>Loaded</c>).
31+
/// </summary>
32+
public sealed class WindowResumeGuard : IDisposable
33+
{
34+
private readonly Window _window;
35+
private readonly Action _restoreToForeground;
36+
37+
/* Seeded true because the app starts visible (App.OnStartup → MainWindow.Show()). This
38+
covers a Resume/SessionUnlock that arrives with no preceding Suspend/Lock snapshot; the
39+
normal sleep/lock path recomputes it first. */
40+
private bool _wasVisibleBeforeSuspend = true;
41+
private bool _disposed;
42+
43+
public WindowResumeGuard(Window window, Action restoreToForeground)
44+
{
45+
_window = window ?? throw new ArgumentNullException(nameof(window));
46+
_restoreToForeground = restoreToForeground ?? throw new ArgumentNullException(nameof(restoreToForeground));
47+
48+
SystemEvents.PowerModeChanged += OnPowerModeChanged;
49+
SystemEvents.SessionSwitch += OnSessionSwitch;
50+
}
51+
52+
/// <summary>
53+
/// Pure decision: restore only when the window was visible before the transition but is
54+
/// now hidden or minimized. A window the user deliberately sent to the tray beforehand
55+
/// (<paramref name="wasVisibleBeforeSuspend"/> == false) is left alone.
56+
/// </summary>
57+
internal static bool ShouldRestore(bool wasVisibleBeforeSuspend, bool isVisibleNow, WindowState state)
58+
=> wasVisibleBeforeSuspend && (!isVisibleNow || state == WindowState.Minimized);
59+
60+
private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e)
61+
{
62+
if (_disposed) return;
63+
64+
if (e.Mode == PowerModes.Suspend)
65+
Snapshot();
66+
else if (e.Mode == PowerModes.Resume)
67+
RestoreIfNeeded();
68+
}
69+
70+
private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e)
71+
{
72+
if (_disposed) return;
73+
74+
if (e.Reason == SessionSwitchReason.SessionLock)
75+
Snapshot();
76+
else if (e.Reason == SessionSwitchReason.SessionUnlock)
77+
RestoreIfNeeded();
78+
}
79+
80+
/* SystemEvents raise on a dedicated, non-UI thread — marshal to the window's dispatcher
81+
before touching it. BeginInvoke (not Invoke) avoids a deadlock if the UI thread is mid
82+
resume-pump; the try/catch covers a late event hitting a dispatcher that is shutting
83+
down between Closing and teardown. */
84+
private void Snapshot()
85+
{
86+
try
87+
{
88+
_window.Dispatcher.BeginInvoke(new Action(() =>
89+
{
90+
if (_disposed) return;
91+
_wasVisibleBeforeSuspend = _window.IsVisible && _window.WindowState != WindowState.Minimized;
92+
}));
93+
}
94+
catch { /* dispatcher shutting down */ }
95+
}
96+
97+
private void RestoreIfNeeded()
98+
{
99+
try
100+
{
101+
_window.Dispatcher.BeginInvoke(new Action(() =>
102+
{
103+
if (_disposed) return;
104+
if (ShouldRestore(_wasVisibleBeforeSuspend, _window.IsVisible, _window.WindowState))
105+
_restoreToForeground();
106+
}));
107+
}
108+
catch { /* dispatcher shutting down */ }
109+
}
110+
111+
public void Dispose()
112+
{
113+
if (_disposed) return;
114+
_disposed = true;
115+
SystemEvents.PowerModeChanged -= OnPowerModeChanged;
116+
SystemEvents.SessionSwitch -= OnSessionSwitch;
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)