|
| 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 | +} |
0 commit comments