Skip to content

[Bug]: Four hot paths cost more than they need to — two WMI round-trips every 300 ms, a WMI call under a shared lock, and a blocking window probe per process #2104

Description

@laurentiu021

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.csCapture(), QueryCpuLoad(), QueryDynamicOs()
  • SysManager/SysManager/Services/ProcessManagerService.csp.Responding, p.MainWindowHandle
  • SysManager/SysManager/Services/ResourceHistoryService.csPrune, PruneAsync
  • SysManager/SysManager/Services/BandwidthHistoryService.csPruneAsync

Affected tabs

Dashboard, Process Manager, Bandwidth Monitor

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperformancePerformance issue

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions