Skip to content

fix: stop holding the snapshot lock across a WMI call, and stop rebuilding pruned lines - #2112

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/perf-lock-scope-and-prune
Sep 4, 2026
Merged

fix: stop holding the snapshot lock across a WMI call, and stop rebuilding pruned lines#2112
laurentiu021 merged 1 commit into
mainfrom
fix/perf-lock-scope-and-prune

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Part of #2104 — the two contained items. The two that need new P/Invoke (replacing the 300 ms WMI round-trips with GetSystemTimes / GlobalMemoryStatusEx, and resolving process window handles once per refresh instead of twice per process) stay open on that issue.

1. A WMI round-trip no longer runs while holding the snapshot cache lock

SystemInfoService.Capture() takes _cacheLock to serialise four ??= caches, each of which queries WMI once per process. QueryCpuLoad() — a per-poll query — was called inside that block, so every concurrent CaptureAsync queued behind a round-trip it had no interest in. The Dashboard polls this at 300 ms, so the queue was rarely empty. It now sits after the block, beside QueryDynamicOs, which was already outside for the same reason.

Guarded by TheSnapshotCacheLock_HoldsOnlyCachedQueries, and the rule is a shape rather than a name list: inside the block, a Query… call must sit on a line that also caches its result with ??=. A third dynamic query added later is caught; a name list could not have been.

The guard slices the block by counting braces (new BalancedBlock helper) rather than reusing MemberSlice, which runs to the end of the file when it cannot find the next member — a "does not appear in here" assertion cannot be built on a slice that might be the whole file.

2. A prune no longer rebuilds every line it keeps

Prune parsed each line, then re-serialised every kept sample, and PruneAsync discards the whole result when nothing was dropped:

var kept = Prune(lines, DateTime.Now, TimeSpan.FromDays(_retentionDays));
if (kept.Count == lines.Length) return;

So the common case — nothing aged out yet — paid a full parse and a full re-serialise for no output. Both history services had the identical shape; both are fixed, so this is the class rather than the instance.

The sort is preserved exactly: pairs of (timestamp, original line) are sorted, and the original text is handed back. Same count, same order, and the kept lines are byte-identical to what was read instead of normalised through Serialize.

Verification

Mutation proof, both files restored byte-for-byte and re-hashed:

Mutation Result
QueryCpuLoad moves back inside lock (_cacheLock) RED on TheSnapshotCacheLock_HoldsOnlyCachedQueries, and only that test
Prune re-serialises its kept lines RED on Prune_HandsBackTheLinesItRead_WithoutReserialisingThem, and only that test

Baseline and post-restore 4 green, 0 red.

The new behaviour test is worth a note on how it is built: it asserts against a line that parses to the right sample but is not what Serialize emits, since comparing to Serialize's own output would pass either way. The variant is the canonical line with one leading space, derived from the serialiser rather than typed — the first attempt used hand-written JSON and went red, because the model serialises to short keys ("t", "c", "r") rather than property names.

Regression sweep: 192 named tests across ArchitectureTests, both history suites, SystemInfo*, DashboardViewModelTests and BandwidthMonitorViewModelTests — 294 cases green. The three reds in that run are two non-test helpers my name-extraction grep picked up (Dispose, ReleaseSample) and the harness-only author-header case for the throwaway runner.

Builds 0 errors / 0 warnings (app + tests), dotnet format --verify-no-changes clean on both, version consistency csproj 1.76.12 = CHANGELOG 1.76.12 = SECURITY 1.76.x.

Not measured here. This is a contention and allocation fix reasoned from the call shape, not a benchmark. #2104 asks for the perf budget to be checked properly — idle CPU% with the Landing tab open, and the wall-clock of one Process Manager refresh on a machine with 300+ processes — and that belongs with the syscall work, on a machine that runs the app.

…lding pruned lines

Two contained items from #2104.

SystemInfoService.Capture takes _cacheLock to serialise four ??= caches that each
query WMI once per process. QueryCpuLoad -- a per-poll query -- ran inside that
block, so every concurrent CaptureAsync queued behind a round-trip it had no
interest in, and the Dashboard polls this at 300 ms. It now sits after the block
beside QueryDynamicOs, which was already outside for the same reason.

Guarded as a shape rather than a name list: inside the block a Query* call must sit
on a line that also caches its result with ??=, so a third dynamic query added
later is caught too. The guard slices the block by counting braces instead of
reusing MemberSlice, which runs to the end of the file when it cannot find the next
member -- a "does not appear in here" assertion cannot rest on that.

Prune parsed every line and then re-serialised every kept sample, and PruneAsync
discards the result whenever nothing was dropped, which is almost every call. Both
history services had the identical shape; both now sort (timestamp, original line)
pairs and hand back the text they read. Same count, same order, no Serialize.

Closes nothing on its own -- #2104 keeps the two syscall items open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant