Problem
Four hot paths do more work than they need to, three of them on a repeating timer. Each was verified against current source.
1. The Landing tab makes two WMI round-trips every 300 ms for numbers two kernel calls already provide
DashboardViewModel's vitals loop runs await Task.Delay(300, ct) and calls SystemInfoService.CaptureAsync() on every pass. The static parts are already cached (_cachedOs, _cachedCpuStatic, _cachedDisks, _cachedModules), but two queries stay dynamic and run 3.3 times a second for as long as the tab is open:
SELECT LoadPercentage FROM Win32_Processor
SELECT LastBootUpTime,TotalVisibleMemorySize,FreePhysicalMemory FROM Win32_OperatingSystem
Both have cheap, non-WMI equivalents that are also more accurate:
- CPU load —
GetSystemTimes deltas. Win32_Processor.LoadPercentage is a coarse, WMI-throttled one-second average, so at a 300 ms poll rate the chart is redrawing values that have not changed.
- physical memory —
GlobalMemoryStatusEx (ullTotalPhys / ullAvailPhys), a single syscall.
- uptime —
Environment.TickCount64, no query at all. The codebase already uses it as a monotonic clock in ConnectionBandwidthSource and DuplicateFileService.
2. That dynamic CPU query runs while holding the static-cache lock
In SystemInfoService.Capture(), QueryCpuLoad() is called inside lock (_cacheLock):
_cachedCpuStatic ??= QueryCpuStatic();
cpu = (_cachedCpuStatic ?? new CpuInfo("Unknown", 0, 0, 0, 0)) with { LoadPercent = QueryCpuLoad() };
The lock exists to protect the ??= caches. A dynamic query needs none of it, and holding the lock across a WMI round-trip serialises every other CaptureAsync caller behind it. QueryDynamicOs is already outside the lock, which is the shape this one should follow.
3. Process Manager resolves every process's main window and sends it a blocking message per refresh
ProcessManagerService reads p.Responding for every process in the snapshot loop, and p.MainWindowHandle a few lines later. On .NET/Windows each of those:
- walks every top-level window in the session (
EnumWindows, filtered by process id) to resolve the main window, once per property read — so twice per process
- then, for
Responding, sends WM_NULL via SendMessageTimeout with SMTO_ABORTIFHUNG and a 5000 ms timeout
With 200-400 processes that is several hundred window enumerations plus several hundred cross-process messages per refresh. A genuinely hung app is the worst case the feature is for, and it is exactly the case that costs the most.
EnumWindows once per refresh, building a pid → hwnd map, would replace all of it. Whether the responsiveness column is worth a blocking probe at all is a separate call — IsHungAppWindow on the resolved handle is cheaper and non-blocking.
4. Both history services deserialise and re-serialise the whole file to prune it
ResourceHistoryService.Prune parses every line into a ResourceSample, sorts, and re-serialises every kept line, then the caller compares counts and often discards the result:
var kept = Prune(lines, DateTime.Now, TimeSpan.FromDays(_retentionDays));
if (kept.Count == lines.Length) return;
So the common case — nothing to prune — pays a full parse and a full re-serialise of the file for no output. BandwidthHistoryService.PruneAsync has the same shape. A prune only needs the timestamp, which is a prefix of each line; the kept lines can be written back verbatim.
Expected behavior
The 300 ms path costs syscalls rather than WMI round-trips; no WMI call is made while holding a lock other callers need; the process list resolves window handles once per refresh instead of twice per process; and a prune that changes nothing does no serialisation work.
Worth a measurement, not just a patch: the perf budget these should be checked against is idle CPU% with the Landing tab open, and the wall-clock of one Process Manager refresh on a machine with 300+ processes.
Evidence
SysManager/SysManager/ViewModels/DashboardViewModel.cs — the 300 ms loop
SysManager/SysManager/Services/SystemInfoService.cs — Capture(), QueryCpuLoad(), QueryDynamicOs()
SysManager/SysManager/Services/ProcessManagerService.cs — p.Responding, p.MainWindowHandle
SysManager/SysManager/Services/ResourceHistoryService.cs — Prune, PruneAsync
SysManager/SysManager/Services/BandwidthHistoryService.cs — PruneAsync
Affected tabs
Dashboard, Process Manager, Bandwidth Monitor
Problem
Four hot paths do more work than they need to, three of them on a repeating timer. Each was verified against current source.
1. The Landing tab makes two WMI round-trips every 300 ms for numbers two kernel calls already provide
DashboardViewModel's vitals loop runsawait Task.Delay(300, ct)and callsSystemInfoService.CaptureAsync()on every pass. The static parts are already cached (_cachedOs,_cachedCpuStatic,_cachedDisks,_cachedModules), but two queries stay dynamic and run 3.3 times a second for as long as the tab is open:SELECT LoadPercentage FROM Win32_ProcessorSELECT LastBootUpTime,TotalVisibleMemorySize,FreePhysicalMemory FROM Win32_OperatingSystemBoth have cheap, non-WMI equivalents that are also more accurate:
GetSystemTimesdeltas.Win32_Processor.LoadPercentageis a coarse, WMI-throttled one-second average, so at a 300 ms poll rate the chart is redrawing values that have not changed.GlobalMemoryStatusEx(ullTotalPhys/ullAvailPhys), a single syscall.Environment.TickCount64, no query at all. The codebase already uses it as a monotonic clock inConnectionBandwidthSourceandDuplicateFileService.2. That dynamic CPU query runs while holding the static-cache lock
In
SystemInfoService.Capture(),QueryCpuLoad()is called insidelock (_cacheLock):The lock exists to protect the
??=caches. A dynamic query needs none of it, and holding the lock across a WMI round-trip serialises every otherCaptureAsynccaller behind it.QueryDynamicOsis already outside the lock, which is the shape this one should follow.3. Process Manager resolves every process's main window and sends it a blocking message per refresh
ProcessManagerServicereadsp.Respondingfor every process in the snapshot loop, andp.MainWindowHandlea few lines later. On .NET/Windows each of those:EnumWindows, filtered by process id) to resolve the main window, once per property read — so twice per processResponding, sendsWM_NULLviaSendMessageTimeoutwithSMTO_ABORTIFHUNGand a 5000 ms timeoutWith 200-400 processes that is several hundred window enumerations plus several hundred cross-process messages per refresh. A genuinely hung app is the worst case the feature is for, and it is exactly the case that costs the most.
EnumWindowsonce per refresh, building a pid → hwnd map, would replace all of it. Whether the responsiveness column is worth a blocking probe at all is a separate call —IsHungAppWindowon the resolved handle is cheaper and non-blocking.4. Both history services deserialise and re-serialise the whole file to prune it
ResourceHistoryService.Pruneparses every line into aResourceSample, sorts, and re-serialises every kept line, then the caller compares counts and often discards the result:So the common case — nothing to prune — pays a full parse and a full re-serialise of the file for no output.
BandwidthHistoryService.PruneAsynchas the same shape. A prune only needs the timestamp, which is a prefix of each line; the kept lines can be written back verbatim.Expected behavior
The 300 ms path costs syscalls rather than WMI round-trips; no WMI call is made while holding a lock other callers need; the process list resolves window handles once per refresh instead of twice per process; and a prune that changes nothing does no serialisation work.
Worth a measurement, not just a patch: the perf budget these should be checked against is idle CPU% with the Landing tab open, and the wall-clock of one Process Manager refresh on a machine with 300+ processes.
Evidence
SysManager/SysManager/ViewModels/DashboardViewModel.cs— the 300 ms loopSysManager/SysManager/Services/SystemInfoService.cs—Capture(),QueryCpuLoad(),QueryDynamicOs()SysManager/SysManager/Services/ProcessManagerService.cs—p.Responding,p.MainWindowHandleSysManager/SysManager/Services/ResourceHistoryService.cs—Prune,PruneAsyncSysManager/SysManager/Services/BandwidthHistoryService.cs—PruneAsyncAffected tabs
Dashboard, Process Manager, Bandwidth Monitor