fix: serialize the Resource History reloads and handle an unreadable history file - #1713
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.ReloadAsyncrebuilds five LiveCharts-observed buffers viaReplaceWith, and has three entry points that can run concurrently:InitializeAsync(() => ReloadAsync())OnSelectedRangeChanged => _ = ReloadAsync()[RelayCommand] ReloadAsyncResourceHistoryView.xamlLiveCharts'
CollectionDeepObservermaintains aHashSetupdated from the change notification, so a second thread arriving mid-notification corrupts it — the exact failure CI hit onBandwidthMonitorViewModel("Operations that change non-concurrent collections must have exclusive access"). That VM got aSemaphoreSlimgate; this one was missed. Same gate here, released infinally, disposed with the VM.2. An unreadable history file escaped the catch
Both history services wrapped their file access in
catch (IOException)only.UnauthorizedAccessExceptionis a sibling ofIOException, 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:
ReloadHistoryAsyncsets_historyOwnsChart = truebefore awaiting and only hands it back onOperationCanceledException— an escaping access error would have frozen the live chart permanently.3. The service was untestable (the reason both slipped through)
ResourceHistoryServicebuilt its paths instatic readonlyfields fromEnvironment.GetFolderPath(SpecialFolder.LocalApplicationData). That resolves through the Win32 known-folder API and ignores theLOCALAPPDATAenvironment 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
configDirseam asBandwidthHistoryService,ClosePreferenceService,CrashMarkerService,PerformanceService,ProfileService,ServiceStartupLedgerService,StandbyPreferenceServiceandVolumePresetService— it was the only one of the 25LocalApplicationDataservices still on hardcoded static paths.Verification
The access-denied fix is red-before / green-after. Proven with a deny-read ACL:
10 passed, 1 failed—THREW UnauthorizedAccessException: Access to the path '...\resource-history.ndjson' is denied.11 passed, 0 failedWorth recording: a directory in the file's place does not reproduce it.
File.Existsreturns false for a directory, soLoadAsyncreturns 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:
configDiris optional so none needed changing.ResourceHistoryServiceresolves, is still a singleton,BandwidthHistoryServicestill resolves, and the default path is unchanged (%LOCALAPPDATA%\SysManager).--no-incremental: 0 errors, 0 warnings.ReplaceWithnever did real work). ASampleCount == 0guard 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-
IOExceptiongap exists at 15 other filesystem call sites (UpdateServicex3,UpdateApplier,SpeedTestHistoryServicex3,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 thewhen (ex is A or B)form).