Skip to content

Commit abd36d8

Browse files
RBridCopilot
andcommitted
SvgImageSource diagnostics for HubWindow nav-icon blank-out repro
Adds passive, logging-only diagnostics to investigate Scott's report of WinUI 3 NavigationView SVG icons going blank after long uptime while FontIcons continue to render. No behavior change. Strategy (event-driven, no false-positive heuristics): - Subscribe to SvgImageSource.Opened/OpenFailed on all 20 sidebar icon resources at HubWindow construction; track which keys have ever opened. - Sanity-check on NavView.Loaded, XamlRoot.Changed, ActualThemeChanged, and a 60s repeating DispatcherQueue timer; warn when a NavigationView ImageIcon still references a tracked SvgImageSource that never saw Opened. - Capture correlation signals via Windows.System.MemoryManager (AppMemoryUsageIncreased / AppMemoryUsageLimitChanging) and snapshot uptime/handles/working-set/memory level on each warning. - Cap warnings at 50/session; defensive global-miss guard logs once if zero of N tracked sources ever opened (suggests handler attach raced decode) instead of burning the cap with per-icon noise. - Skip entirely when high-contrast fallback swaps SVGs for FontIcons. - TeardownSvgDiagnostics() in Closed handler stops the timer and unhooks static MemoryManager events to avoid window leaks across the App.xaml.cs open/close cycle. Reviewed via three rounds of dual-model adversarial review (Claude Opus 4.6 + GPT-5.2-Codex): - r1: replaced incorrect RasterizePixelWidth/Height heuristic (input property, not decode output) with event-driven Opened tracking; added TeardownSvgDiagnostics() to plug static MemoryManager leak; try/catch hardened OnAppMemoryUsageLimitChanging. - r2: disposed Process.GetCurrentProcess handle; added defensive guard for hypothetical Opened-before-attach race. - r3: clean. Validation: ./build.ps1, Shared 2023/29 skipped, Tray 862/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 191f4d6 commit abd36d8

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Text;
5+
using Microsoft.UI.Xaml;
6+
using Microsoft.UI.Xaml.Controls;
7+
using Microsoft.UI.Xaml.Media.Imaging;
8+
using OpenClawTray.Services;
9+
using WinMemoryManager = Windows.System.MemoryManager;
10+
using WinAppMemoryUsageLimitChangingEventArgs = Windows.System.AppMemoryUsageLimitChangingEventArgs;
11+
12+
namespace OpenClawTray.Windows;
13+
14+
// Diagnostics scaffolding for the colorful SvgImageSource sidebar icons.
15+
// Motivation: a Scott-reported repro showed the left-nav icons going completely
16+
// blank after long uptime while the rest of the UI (FontIcon-based glyphs)
17+
// rendered fine. WinUI 3 does not use GDI; the suspected failure path is
18+
// silent D2D/D3D rasterization failure of SvgImageSource under memory or
19+
// device-lost pressure.
20+
//
21+
// Detection strategy: hook both SvgImageSource.Opened (success) and OpenFailed
22+
// (parse/URI failure) on every NavView resource key at construction time, BEFORE
23+
// any consumer has caused decode. Track which keys ever reported Opened. The
24+
// sanity check then walks NavigationView items and warns about any ImageIcon
25+
// whose backing SvgImageSource never produced an Opened event. We deliberately
26+
// do not rely on RasterizePixelWidth/Height -- those are input properties (target
27+
// raster size), not decode-status outputs.
28+
public sealed partial class HubWindow
29+
{
30+
private static readonly TimeSpan SvgDiagnosticsCheckInterval = TimeSpan.FromSeconds(60);
31+
32+
// Resource-dictionary keys for SvgImageSource entries declared at NavView.Resources
33+
// in HubWindow.xaml. Kept explicit so we know exactly which icons are expected to load.
34+
private static readonly string[] s_sidebarIconResourceKeys =
35+
{
36+
"Chat_Icon", "Connection_Icon", "Sessions_Icon", "Skills_Icon",
37+
"Channels_Icon", "Instances_Icon", "Advanced_Icon", "AgentEvents_Icon",
38+
"Agents_Icon", "Bindings_Icon", "Config_Icon", "Usage_Icon",
39+
"Cron_Icon", "Voice_Icon", "Settings_Icon", "Permissions_Icon",
40+
"Sandbox_Icon", "Activity_Icon", "Debug_Icon", "Info_Icon",
41+
};
42+
43+
// Cap on blank-icon warnings per process so a stuck failure doesn't fill the log.
44+
private const int MaxBlankIconLogs = 50;
45+
46+
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _svgDiagnosticsTimer;
47+
private DateTime? _lastThemeChangeUtc;
48+
private DateTime? _lastXamlRootChangeUtc;
49+
private int _blankIconLogCount;
50+
private bool _svgDiagnosticsInitialized;
51+
private bool _memoryManagerHooked;
52+
private bool _globalTimingMissLogged;
53+
54+
// Reverse map from each tracked SvgImageSource back to its resource key, so the
55+
// sanity check can identify which icon a nav ImageIcon is referencing.
56+
private readonly Dictionary<SvgImageSource, string> _trackedSvgKeysByInstance = new();
57+
58+
// Keys for which SvgImageSource.Opened has fired. Used as the success signal
59+
// (replaces the unreliable RasterizePixelWidth==0 heuristic from earlier).
60+
private readonly HashSet<string> _openedSvgKeys = new(StringComparer.Ordinal);
61+
62+
private void InitializeSvgDiagnostics()
63+
{
64+
if (_svgDiagnosticsInitialized) return;
65+
_svgDiagnosticsInitialized = true;
66+
67+
// High-contrast mode replaces the SVG icons with FontIcons at construction,
68+
// so there is nothing meaningful to monitor on the SVG decode path.
69+
if (_isHighContrast)
70+
{
71+
Logger.Debug("[SvgDiag] Skipping init (HighContrast active; FontIcons in use).");
72+
return;
73+
}
74+
75+
try
76+
{
77+
HookSvgEventHandlers();
78+
HookMemoryPressureListener();
79+
80+
NavView.Loaded += OnNavViewLoadedForSvgDiagnostics;
81+
82+
_svgDiagnosticsTimer = DispatcherQueue.CreateTimer();
83+
_svgDiagnosticsTimer.Interval = SvgDiagnosticsCheckInterval;
84+
_svgDiagnosticsTimer.IsRepeating = true;
85+
_svgDiagnosticsTimer.Tick += OnSvgDiagnosticsTimerTick;
86+
_svgDiagnosticsTimer.Start();
87+
88+
Logger.Info($"[SvgDiag] Initialized (interval={SvgDiagnosticsCheckInterval.TotalSeconds}s, icons={s_sidebarIconResourceKeys.Length}).");
89+
}
90+
catch (Exception ex)
91+
{
92+
Logger.Warn($"[SvgDiag] InitializeSvgDiagnostics failed: {ex.Message}");
93+
}
94+
}
95+
96+
private void HookSvgEventHandlers()
97+
{
98+
// Subscribed at ctor time, before any consumer triggers decode. Tracking the
99+
// Opened event lets the sanity check tell "this source never decoded" apart
100+
// from "this source decoded fine" without depending on the (input-only)
101+
// Rasterize* properties.
102+
foreach (var key in s_sidebarIconResourceKeys)
103+
{
104+
if (NavView.Resources.TryGetValue(key, out var value) && value is SvgImageSource svg)
105+
{
106+
_trackedSvgKeysByInstance[svg] = key;
107+
108+
var capturedKey = key;
109+
var capturedSvg = svg;
110+
svg.Opened += (sender, args) =>
111+
{
112+
_openedSvgKeys.Add(capturedKey);
113+
};
114+
svg.OpenFailed += (sender, args) =>
115+
{
116+
// OpenFailed signals parse/URI/IO failure at the initial Open step.
117+
// Post-load rasterization failures (the suspected long-uptime case)
118+
// are caught by the sanity check seeing the key missing from
119+
// _openedSvgKeys -- though for sources that opened once and then lose
120+
// their backing surface, neither signal fires. That class of failure
121+
// is what the XamlRoot.Changed / memory-pressure correlations exist for.
122+
Logger.Warn($"[SvgDiag] SvgImageSource.OpenFailed key={capturedKey} uri={capturedSvg.UriSource} status={args.Status}");
123+
};
124+
}
125+
else
126+
{
127+
Logger.Debug($"[SvgDiag] Missing or non-SVG resource key: {key}");
128+
}
129+
}
130+
}
131+
132+
private void HookMemoryPressureListener()
133+
{
134+
try
135+
{
136+
WinMemoryManager.AppMemoryUsageIncreased += OnAppMemoryUsageIncreased;
137+
WinMemoryManager.AppMemoryUsageLimitChanging += OnAppMemoryUsageLimitChanging;
138+
_memoryManagerHooked = true;
139+
}
140+
catch (Exception ex)
141+
{
142+
// MemoryManager requires packaged identity. If we are running unpackaged
143+
// (e.g. dev builds), the API will throw on subscription and we degrade silently.
144+
Logger.Debug($"[SvgDiag] MemoryManager hooks unavailable: {ex.Message}");
145+
}
146+
}
147+
148+
// Teardown for state that must be released when the window closes. Called from the
149+
// HubWindow.xaml.cs Closed handler. The static MemoryManager events would otherwise
150+
// root this HubWindow instance forever, leaking the window (and its entire visual
151+
// tree) across every open/close cycle.
152+
internal void TeardownSvgDiagnostics()
153+
{
154+
_svgDiagnosticsTimer?.Stop();
155+
if (_memoryManagerHooked)
156+
{
157+
try
158+
{
159+
WinMemoryManager.AppMemoryUsageIncreased -= OnAppMemoryUsageIncreased;
160+
WinMemoryManager.AppMemoryUsageLimitChanging -= OnAppMemoryUsageLimitChanging;
161+
}
162+
catch (Exception ex)
163+
{
164+
Logger.Debug($"[SvgDiag] MemoryManager unhook failed: {ex.Message}");
165+
}
166+
_memoryManagerHooked = false;
167+
}
168+
}
169+
170+
private void OnAppMemoryUsageIncreased(object? sender, object args)
171+
{
172+
try
173+
{
174+
var level = WinMemoryManager.AppMemoryUsageLevel;
175+
var usage = WinMemoryManager.AppMemoryUsage;
176+
var limit = WinMemoryManager.AppMemoryUsageLimit;
177+
Logger.Info($"[SvgDiag] AppMemoryUsageIncreased level={level} usage={usage} limit={limit}");
178+
}
179+
catch (Exception ex)
180+
{
181+
Logger.Debug($"[SvgDiag] AppMemoryUsageIncreased read failed: {ex.Message}");
182+
}
183+
}
184+
185+
private void OnAppMemoryUsageLimitChanging(object? sender, WinAppMemoryUsageLimitChangingEventArgs args)
186+
{
187+
// Fires on a thread-pool thread (WinRT static event). An unhandled exception
188+
// here would terminate the process via the runtime's unhandled-exception path,
189+
// so we mirror the try/catch hardening from OnAppMemoryUsageIncreased.
190+
try
191+
{
192+
Logger.Info($"[SvgDiag] AppMemoryUsageLimitChanging old={args.OldLimit} new={args.NewLimit}");
193+
}
194+
catch (Exception ex)
195+
{
196+
try { Logger.Debug($"[SvgDiag] AppMemoryUsageLimitChanging log failed: {ex.Message}"); } catch { }
197+
}
198+
}
199+
200+
private void OnSvgDiagnosticsTimerTick(Microsoft.UI.Dispatching.DispatcherQueueTimer sender, object args)
201+
{
202+
RunSvgIconSanityCheck("timer");
203+
}
204+
205+
private void OnNavViewLoadedForSvgDiagnostics(object sender, RoutedEventArgs e)
206+
{
207+
try
208+
{
209+
if (NavView.XamlRoot is { } root)
210+
{
211+
root.Changed -= OnXamlRootChangedForSvgDiagnostics;
212+
root.Changed += OnXamlRootChangedForSvgDiagnostics;
213+
}
214+
215+
NavView.ActualThemeChanged -= OnNavViewThemeChangedForSvgDiagnostics;
216+
NavView.ActualThemeChanged += OnNavViewThemeChangedForSvgDiagnostics;
217+
218+
// Baseline check after layout so RasterizePixelWidth has stabilized.
219+
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
220+
() => RunSvgIconSanityCheck("loaded"));
221+
}
222+
catch (Exception ex)
223+
{
224+
Logger.Warn($"[SvgDiag] OnNavViewLoadedForSvgDiagnostics failed: {ex.Message}");
225+
}
226+
}
227+
228+
private void OnXamlRootChangedForSvgDiagnostics(XamlRoot sender, XamlRootChangedEventArgs args)
229+
{
230+
_lastXamlRootChangeUtc = DateTime.UtcNow;
231+
try
232+
{
233+
Logger.Info($"[SvgDiag] XamlRoot.Changed scale={sender.RasterizationScale:F2} size={sender.Size.Width:F0}x{sender.Size.Height:F0} visible={sender.IsHostVisible}");
234+
}
235+
catch (Exception ex)
236+
{
237+
Logger.Debug($"[SvgDiag] XamlRoot.Changed read failed: {ex.Message}");
238+
}
239+
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
240+
() => RunSvgIconSanityCheck("xamlroot-changed"));
241+
}
242+
243+
private void OnNavViewThemeChangedForSvgDiagnostics(FrameworkElement sender, object args)
244+
{
245+
_lastThemeChangeUtc = DateTime.UtcNow;
246+
Logger.Info($"[SvgDiag] NavView.ActualThemeChanged theme={sender.ActualTheme}");
247+
DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
248+
() => RunSvgIconSanityCheck("theme-changed"));
249+
}
250+
251+
private void RunSvgIconSanityCheck(string trigger)
252+
{
253+
if (IsClosed) return;
254+
if (_blankIconLogCount >= MaxBlankIconLogs) return;
255+
256+
try
257+
{
258+
var unopened = new List<string>();
259+
CollectUnopenedIconNavItems(NavView.MenuItems, unopened);
260+
CollectUnopenedIconNavItems(NavView.FooterMenuItems, unopened);
261+
262+
if (unopened.Count == 0)
263+
return;
264+
265+
// Defensive guard: if we tracked SvgImageSources but never saw a single Opened event,
266+
// the most likely explanation is that handler attachment raced the initial decode (not
267+
// that every icon actually failed). Log once and short-circuit to avoid log-cap burn.
268+
if (_openedSvgKeys.Count == 0 && _trackedSvgKeysByInstance.Count > 0)
269+
{
270+
if (!_globalTimingMissLogged)
271+
{
272+
_globalTimingMissLogged = true;
273+
Logger.Warn($"[SvgDiag] No Opened events observed for any tracked SvgImageSource (trigger={trigger}, tracked={_trackedSvgKeysByInstance.Count}); handlers may have been attached after initial decode. Suppressing per-icon warnings for this session.");
274+
}
275+
return;
276+
}
277+
278+
_blankIconLogCount++;
279+
var snapshot = CaptureDiagnosticSnapshot();
280+
Logger.Warn($"[SvgDiag] Unopened SvgImageSource icons detected (trigger={trigger}) count={unopened.Count} icons=[{string.Join(", ", unopened)}] openedCount={_openedSvgKeys.Count}/{_trackedSvgKeysByInstance.Count} {snapshot}");
281+
282+
if (_blankIconLogCount == MaxBlankIconLogs)
283+
Logger.Warn($"[SvgDiag] Blank-icon log cap reached ({MaxBlankIconLogs}); suppressing further warnings this session.");
284+
}
285+
catch (Exception ex)
286+
{
287+
Logger.Warn($"[SvgDiag] RunSvgIconSanityCheck({trigger}) failed: {ex.Message}");
288+
}
289+
}
290+
291+
private void CollectUnopenedIconNavItems(IList<object> items, List<string> unopened)
292+
{
293+
foreach (var obj in items)
294+
{
295+
if (obj is not NavigationViewItem item) continue;
296+
if (item.Icon is ImageIcon imageIcon
297+
&& imageIcon.Source is SvgImageSource svg
298+
&& _trackedSvgKeysByInstance.TryGetValue(svg, out var resourceKey)
299+
&& !_openedSvgKeys.Contains(resourceKey))
300+
{
301+
var navName = item.Tag as string ?? item.Content as string ?? "(unnamed)";
302+
unopened.Add($"{navName}={resourceKey}");
303+
}
304+
if (item.MenuItems.Count > 0)
305+
CollectUnopenedIconNavItems(item.MenuItems, unopened);
306+
}
307+
}
308+
309+
private string CaptureDiagnosticSnapshot()
310+
{
311+
var sb = new StringBuilder();
312+
try
313+
{
314+
using var proc = Process.GetCurrentProcess();
315+
var uptimeSec = (DateTime.UtcNow - proc.StartTime.ToUniversalTime()).TotalSeconds;
316+
sb.Append($"uptimeSec={uptimeSec:F0} ");
317+
sb.Append($"handles={proc.HandleCount} ");
318+
sb.Append($"workingSetMb={proc.WorkingSet64 / (1024 * 1024)} ");
319+
}
320+
catch (Exception ex)
321+
{
322+
sb.Append($"procReadErr={ex.GetType().Name} ");
323+
}
324+
try
325+
{
326+
sb.Append($"memUsage={WinMemoryManager.AppMemoryUsage} ");
327+
sb.Append($"memLimit={WinMemoryManager.AppMemoryUsageLimit} ");
328+
sb.Append($"memLevel={WinMemoryManager.AppMemoryUsageLevel} ");
329+
}
330+
catch
331+
{
332+
// MemoryManager not available (unpackaged); skip silently.
333+
}
334+
if (_lastThemeChangeUtc.HasValue)
335+
sb.Append($"sinceThemeChangeSec={(DateTime.UtcNow - _lastThemeChangeUtc.Value).TotalSeconds:F0} ");
336+
if (_lastXamlRootChangeUtc.HasValue)
337+
sb.Append($"sinceXamlRootChangeSec={(DateTime.UtcNow - _lastXamlRootChangeUtc.Value).TotalSeconds:F0} ");
338+
return sb.ToString().TrimEnd();
339+
}
340+
}

src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public HubWindow()
8181
{
8282
IsClosed = true;
8383
_gatewayNavHideTimer?.Stop();
84+
TeardownSvgDiagnostics();
8485
if (AppModel != null)
8586
AppModel.PropertyChanged -= OnAppModelChanged;
8687
};
@@ -90,6 +91,7 @@ public HubWindow()
9091
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
9192

9293
RootGrid.SizeChanged += OnRootGridSizeChanged;
94+
InitializeSvgDiagnostics();
9395
}
9496

9597
/// <summary>

0 commit comments

Comments
 (0)