Skip to content

fix: serialize the Resource History reloads and handle an unreadable history file - #1713

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/resource-history-reload-race
Aug 6, 2026
Merged

fix: serialize the Resource History reloads and handle an unreadable history file#1713
laurentiu021 merged 1 commit into
mainfrom
fix/resource-history-reload-race

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

What this fixes

Two defects in the history/charting path, both found during the post-batch audit rather than reported.

1. The Resource History reload race (the same one fixed on Bandwidth Monitor in 1.57.3)

ResourceHistoryViewModel.ReloadAsync rebuilds five LiveCharts-observed buffers via ReplaceWith, and has three entry points that can run concurrently:

Entry point Where
InitializeAsync(() => ReloadAsync()) constructor
OnSelectedRangeChanged => _ = ReloadAsync() fire-and-forget on range change
[RelayCommand] ReloadAsync the Refresh button in ResourceHistoryView.xaml

LiveCharts' CollectionDeepObserver maintains a HashSet updated from the change notification, so a second thread arriving mid-notification corrupts it — the exact failure CI hit on BandwidthMonitorViewModel ("Operations that change non-concurrent collections must have exclusive access"). That VM got a SemaphoreSlim gate; this one was missed. Same gate here, released in finally, disposed with the VM.

2. An unreadable history file escaped the catch

Both history services wrapped their file access in catch (IOException) only. UnauthorizedAccessException is a sibling of IOException, not a subclass, so a permission error propagated out. These run in an always-on background sampler the user never invoked, so it surfaced as a failure with no action behind it.

On the bandwidth side it was worse: ReloadHistoryAsync sets _historyOwnsChart = true before awaiting and only hands it back on OperationCanceledException — an escaping access error would have frozen the live chart permanently.

3. The service was untestable (the reason both slipped through)

ResourceHistoryService built its paths in static readonly fields from Environment.GetFolderPath(SpecialFolder.LocalApplicationData). That resolves through the Win32 known-folder API and ignores the LOCALAPPDATA environment variable — verified with a probe — so neither a test nor a child process could redirect it off the real profile. Every existing test was confined to the pure helpers, leaving load, prune and retention uncovered.

It now takes the same optional configDir seam as BandwidthHistoryService, ClosePreferenceService, CrashMarkerService, PerformanceService, ProfileService, ServiceStartupLedgerService, StandbyPreferenceService and VolumePresetService — it was the only one of the 25 LocalApplicationData services still on hardcoded static paths.

Verification

The access-denied fix is red-before / green-after. Proven with a deny-read ACL:

  • before: 10 passed, 1 failedTHREW UnauthorizedAccessException: Access to the path '...\resource-history.ndjson' is denied.
  • after: 11 passed, 0 failed

Worth recording: a directory in the file's place does not reproduce it. File.Exists returns false for a directory, so LoadAsync returns at its guard and never reaches the read — my first version of that test passed against the unfixed code and proved nothing. The deny-read ACL is the mechanism that actually raises it.

The reload gate is not locally reproducible, on either VM. LiveCharts only attaches its deep observer to a rendered chart, so the corruption cannot occur headlessly — a control build with the gate removed still passed 40 rounds x ~81 overlapping reloads. The gate is justified by CI's proven failure on the structurally identical sibling; the test asserts the reachable invariant (series stay coherent, nothing escapes) and says so in a comment rather than implying a repro.

Regression sweep:

  • All three construction sites checked; configDir is optional so none needed changing.
  • DI re-verified at runtime: ResourceHistoryService resolves, is still a singleton, BandwidthHistoryService still resolves, and the default path is unchanged (%LOCALAPPDATA%\SysManager).
  • All four projects rebuild --no-incremental: 0 errors, 0 warnings.
  • An earlier harness returned a confident PASS that was vacuous (the profile's newest sample was five weeks old, so every range filtered to zero and ReplaceWith never did real work). A SampleCount == 0 guard now fails that case explicitly.

Tests added

ResourceHistoryServiceDiskTests (9) — the seam itself, range filtering, oldest-first ordering, malformed-line skipping, the unreadable-file path, and retention persistence across instances.
ResourceHistoryViewModelReloadTests (3) — concurrent reloads keep the series coherent, a redundant refresh still lands on its range and clears the progress bar, and no history reports the empty state.

Not in this PR

The same unpaired-IOException gap exists at 15 other filesystem call sites (UpdateService x3, UpdateApplier, SpeedTestHistoryService x3, ProfileService, FileShredderService, and others). Each needs its own reachability check, and bundling 15 files into a bug fix would break minimal-diff discipline — tracked separately. A mechanical guard is worth considering there, since the dominant house idiom is sequential paired catches (25 occurrences vs 3 of the when (ex is A or B) form).

…history file

The Bandwidth Monitor's concurrent-reload race was fixed in 1.57.3, but
ResourceHistoryViewModel carried the identical defect and was missed: three
entry points (the constructor's InitializeAsync, the fire-and-forget in
OnSelectedRangeChanged, and the Refresh command) each call ReplaceWith on five
buffers LiveCharts observes, with no gate. Its CollectionDeepObserver updates a
HashSet from the change notification, so a second thread arriving mid-notification
corrupts it. Same SemaphoreSlim gate as the sibling, disposed with the VM.

Both history services also caught only IOException around their file access.
UnauthorizedAccessException is a sibling of IOException, not a subclass, so a
permission error escaped — and because these run in a background sampler the user
never invoked, that surfaced as a failure with no user action behind it. On the
bandwidth side it was worse: the caller sets _historyOwnsChart before awaiting and
only hands it back on OperationCanceledException, so an escaping access error would
have frozen the live chart permanently.

ResourceHistoryService's paths were static readonly over
SpecialFolder.LocalApplicationData, which resolves through the Win32 known-folder
API and ignores the LOCALAPPDATA environment variable — so the service could not be
pointed anywhere else by a test or even a child process, and its load, prune and
retention paths had no coverage at all. It now takes the same optional configDir
seam as BandwidthHistoryService and the other seven persistence services, which is
what let the regression tests below exist.

Verification: the access-denied case was proven red before the fix and green after,
using a deny-read ACL (a directory in the file's place does NOT reproduce it, since
File.Exists returns false and the load guard short-circuits) — 10 pass / 1 fail
before, 11 / 0 after, failing with the expected UnauthorizedAccessException. The
reload gate cannot be reproduced headlessly on either VM: LiveCharts only attaches
its deep observer to a rendered chart, so the corruption needs a live UI. It is
justified by CI's proven failure on the structurally identical sibling, and the test
asserts the reachable invariant instead of claiming a repro. DI resolution and the
singleton contract were re-verified, and all four projects rebuild with 0 warnings.
@laurentiu021
laurentiu021 merged commit 7a1f302 into main Aug 6, 2026
4 checks passed
@laurentiu021
laurentiu021 deleted the fix/resource-history-reload-race branch August 6, 2026 13:44
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