Skip to content

feat: surface recorded bandwidth history, expose the driver filter, and make the progress bars real - #1704

Merged
laurentiu021 merged 6 commits into
mainfrom
fix/service-ledger-history-and-feedback
Aug 5, 2026
Merged

feat: surface recorded bandwidth history, expose the driver filter, and make the progress bars real#1704
laurentiu021 merged 6 commits into
mainfrom
fix/service-ledger-history-and-feedback

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Four verified issues plus the propagated class of one of them. Every claim in each issue was re-confirmed against current source before any code was written.

Closes #1609
Closes #1602
Closes #1604
Closes #1603

#1609 — Bandwidth history was written for 7 days and never read

BandwidthMonitorViewModel wrote a throughput sample every ~5s and pruned to a 7-day window, but LoadAsync and Downsample had zero production callers — so the file grew for a week and the user could never see any of it. The service's own doc comment promised the opposite: "so the Bandwidth Monitor can draw the last hour/day/week".

Chose option (a) from the issue (surface it) over (b) (delete the writer), because "where did my data cap go?" is exactly what the target persona asks and the collection half was already shipped.

  • Range picker: Live plus last hour / 24 hours / 7 days, capped at RetentionDays so no range can promise data the pruner already deleted (there is a test asserting that invariant).
  • The loaded series is downsampled to 400 points, matching Resource History's cap — a week of 5-second samples is ~120k points.
  • The live poll stops appending while a stored range is shown, or each tick would push a "now" point onto a week-long series and squash it.
  • Summary reports what actually moved: Downloaded 4.2 GB · Uploaded 380 MB · Peak ↓ … ↑ ….

The maths matters here. Each sample is a rate, not a volume, so the totals integrate each rate over the gap to the next sample rather than summing them. Gaps longer than 4× the write cadence are skipped entirely: samples exist only while the tab is open, so crediting the last known rate across a closed-tab hour would fabricate ~3.7 GB of traffic the user never used. Both behaviours are pinned by tests.

The axis labeller now adapts to the range — a bare HH:mm:ss repeats seven times across a week and hides which day a spike was on.

#1602 — the Drivers filter had no UI at all

HideSystemDrivers had a change handler, filtering logic, and the status text that adjusts the count — and zero bindings in DriversView.xaml. Nobody could reach it. Now a toolbar checkbox matching the TaskSchedulerView / ContextMenuView pattern.

Also split the empty state while in there: on a machine where every driver is Microsoft-supplied, the single shared state told the user to click a button they had already clicked. Same defect I fixed in the Logs tab, so it uses the same two-state shape (HasNotScanned vs HasNoResults).

#1604 — Enable silently downgraded Automatic services to Manual

Disable recorded the previous startup type in a plain property on ServiceEntry, and GetAllServices builds new instances on every scan. So: disable an Automatic service, refresh (or restart), press Enable → it came back as Manual, because StartTypeToScToken maps an unknown value to demand, and the status line reported success the whole time. A silent, unrequested change to the machine's configuration.

Now persisted by ServiceStartupLedgerService, the same shape as the three sibling preference services (injectable directory, pure Serialize/Parse, IO that never throws). Two deliberate constraints:

  • Only the four types Windows accepts are stored; an unrecognised or Disabled value is refused rather than stored, so a restore Windows would reject is never attempted.
  • Rehydration applies only to services Windows currently reports as Disabled. If the user re-enabled one outside SysManager, the machine is the authority — a stale ledger entry cannot override it.

#1603 — three progress bars that could not appear, and four more the issue never found

CpuAffinityView, DisplayProfileView and StandbyMemoryView each drew a ProgressBar bound to IsBusy that their VM never assigned (grep -c IsBusy → 0 for all of them). Structurally incapable of appearing, and since NavItem.WireBusy forwards the same flag, the sidebar spinner was dead for those tabs too — while the work behind them is genuinely slow (enumerating every process and reading its affinity; ChangeDisplaySettingsEx blocking for seconds while the panel re-trains, possibly on a black screen).

Following the issue's own guidance, TimerResolution is left alone — its calls are sub-millisecond, so a bar there would only flicker. Standby's 2 s auto-purge tick is likewise excluded, with a comment and a test so the next refactor doesn't "fix" the asymmetry back.

Propagate

Grepping every view that binds IsBusy against its VM found four more with the identical defect that the issue never named: AudioMixer, Cleanup, NotificationBlocker, Privacy. Fixed the class rather than the instances — the sweep now reports zero dead bars app-wide.

Cleanup was the worst of them: SFC and DISM run for minutes behind a bar that could not appear. Its flag is derived from the four per-operation flags rather than assigned per command, so two overlapping operations can't clear the bar out from under each other, and it stays determinate for SFC/DISM which report a real percentage through the runner. DisplayProfile's overlapping mode loads got a generation counter for the same class of reason: only the newest load may release the flag.

Verification

Local dotnet test is not run on this workstation, so behaviour was proven by a 41-check console harness against the built assembly (not the xUnit suite): ledger persistence across instances, the history round-trip through a real temp file, range filtering, downsampling, the gap-integration maths, axis labels, the driver predicate, live IsBusy transitions, and DI resolution. 41/41 passed. The DI checks exist because nothing else in the suite exercises the container and this PR changes two container-resolved constructors — a wiring break would otherwise only surface at app startup.

Two harness findings turned out to be mine, not the code's, and are worth stating since they shaped the tests:

  1. It seeded history samples out of chronological order, violating a precondition both history services document ("the file is append-only and time-ordered") and that LoadAsync relies on to stop at the window boundary instead of parsing a 120k-line file per load. The production writer always stamps DateTime.Now, so the file cannot be out of order. Fixed the harness — then made the precondition mechanical with an assert in the test seeding helper, so a future test can't silently reproduce it and read as a range-filtering bug.
  2. It hung driving Cleanup's pre-scan, which on this machine walks 20,776 temp files (13.4 GB) plus 163,188 in the Recycle Bin — measured, and precisely why that bar was worth fixing. Those assertions were reshaped so their duration doesn't depend on the developer's disk state.

Also in scope, both to avoid duplication rather than as drive-by refactoring:

  • BandwidthHistoryService's directory is now injectable (matching VolumePresetService and the other preference services), so tests exercise the real append/load/prune paths without touching the user's own history file.
  • ResourceHistoryViewModel's private RangeOption record became the shared HistoryRange model instead of being copied into a second tab, so the two pickers can't drift.

Checks run

  • All four projects: 0 warnings, 0 errors.
  • Leak scan against all 32 patterns in leak-terms.txt: zero hits.
  • Author headers verified on all 20 touched files.
  • Propagate sweep re-run after the fix: 0 remaining dead progress bars.
  • No Console.WriteLine / TODO / HACK / MessageBox.Show; the two generic catch (Exception) blocks flagged in the touched files are pre-existing async-void/poll-loop last-resort nets, confirmed absent from this diff.

Deferred to the secondary workstation

Launching the published .exe, FlaUI, and screenshot recapture — this workstation never runs the app. The Bandwidth Monitor range picker and the Drivers checkbox are new visible UI, so a screenshot pass is worth doing there.

Docs

README (Bandwidth Monitor + Drivers sections), ARCHITECTURE (the new ledger service and the bandwidth read path, including why the gap rule exists), CHANGELOG, and the version bump to 1.56.15 are all in this PR.

…nd make the progress bars real

Four verified issues, each re-confirmed against current source before any code was touched.

#1609 Bandwidth Monitor wrote a throughput sample to disk every ~5s and pruned to a 7-day
window, but LoadAsync and Downsample had ZERO production callers — so the file grew for a week
and the user could never see any of it. The service's own doc comment promised the opposite
("so the Bandwidth Monitor can draw the last hour/day/week"). Added a range picker (Live plus
last hour / 24 hours / 7 days, capped at the service's own retention so no range can promise
data that was pruned) and a summary of what actually moved over the period.

The summary integrates each rate over the gap to the next sample rather than summing rates, and
skips gaps longer than 4x the write cadence: samples exist only while the tab is open, so
crediting the last known rate across a closed-tab hour would fabricate ~3.7 GB of traffic. The
axis labeller adapts to the range too — a bare "HH:mm:ss" repeats seven times across a week and
hides which day a spike was on.

#1602 Drivers had a working HideSystemDrivers filter — change handler, filtering logic, and the
status text that adjusts the count — with zero bindings in the view. Nobody could reach it. Now
a toolbar checkbox. Also split the empty state: on a machine where every driver is
Microsoft-supplied, the single shared state told the user to click a button they had already
clicked (same defect I fixed in the Logs tab).

#1604 Disable recorded a service's previous startup type in a property on ServiceEntry — and
GetAllServices builds new instances on every scan. So Disable, Refresh, Enable brought an
Automatic service back as Manual (StartTypeToScToken maps an unknown value to "demand") while
the status line reported success. Silent, unrequested change to the machine's configuration.
Now persisted via ServiceStartupLedgerService, same shape as the three sibling preference
services. Rehydration applies ONLY to services Windows currently reports as Disabled, so if the
user re-enabled one outside SysManager a stale entry cannot override reality.

#1603 CpuAffinity, DisplayProfile and StandbyMemory each drew a progress bar bound to IsBusy
that their VM never assigned — structurally incapable of appearing, and the sidebar spinner was
dead for them too. TimerResolution correctly needs none (sub-millisecond calls), so it is left
alone, and Standby's 2s auto-purge tick is deliberately excluded with a comment so the next
refactor does not "fix" the asymmetry back.

PROPAGATE: grepping every view that binds IsBusy found FOUR more with the identical defect that
the issue never named — AudioMixer, Cleanup, NotificationBlocker, Privacy. Fixed the class, not
the instances; the sweep now reports zero dead bars app-wide. Cleanup was the worst of them: SFC
and DISM run for minutes behind a bar that could not appear. Its flag is DERIVED from the four
per-operation flags rather than assigned per command, so overlapping operations cannot clear the
bar out from under each other, and it stays determinate for SFC/DISM which report a real
percentage. DisplayProfile's overlapping mode loads got a generation counter for the same
reason.

Verification, not assertion. A 41-check console harness ran against the BUILT assembly (allowed
on this workstation; not the xUnit suite): ledger persistence across instances, the history
round-trip through a real temp file, range filtering, downsampling, the gap-integration maths,
axis labels, the driver predicate, live IsBusy transitions, and DI resolution — 41/41, and the
DI check exists because nothing else in the suite exercises the container and I changed two
container-resolved constructors.

Two harness findings were mine, not the code's. It seeded history samples out of order,
violating a precondition both history services document ("append-only and time-ordered") and
that LoadAsync relies on to avoid parsing a 120k-line file per load; the production writer
always stamps DateTime.Now, so the file cannot be out of order. Fixed the harness, then made
the precondition mechanical with an assert in the test seeding helper so a future test cannot
silently reproduce it. It also hung driving Cleanup's pre-scan, which on this machine walks
20,776 temp files (13.4 GB) plus 163,188 in the Recycle Bin — measured, and precisely why that
bar was worth fixing. Those assertions were reshaped to not depend on the developer's disk
state.

BandwidthHistoryService's directory is now injectable (matching VolumePresetService and the
other preference services), so tests exercise the real append/load/prune paths without touching
the user's own history file. ResourceHistoryViewModel's private RangeOption record became the
shared HistoryRange model rather than being duplicated, so the two pickers cannot drift.

All four projects build with 0 warnings and 0 errors. Full leak scan against all 32 patterns in
leak-terms.txt: zero hits. Author headers verified on every one of the 20 touched files.
…ayProfile's chained init

Two CI failures, both mine (2 failed / 4033).

CleanupViewModelTests.IsProgressIndeterminate_TogglesCleanly — a long-standing test asserting the
flag starts false. PreScanAsync set IsBusy/IsProgressIndeterminate BEFORE its first await, so the
CONSTRUCTOR returned with the bar already on. The test was right and the code was wrong: the tab
is not busy until the scan is actually running, and construction should have no visible side
effect. Fixed with an await Task.Yield() before the assignment, not by touching the test. My own
new test had asserted the synchronous raise, so it was corrected to state the real contract.

DisplayProfileViewModelTests.AfterInit_TheBusyFlagIsClear — my own test, wrong about the seam.
Init CHAINS: LoadDisplaysAsync assigns SelectedDisplay, whose handler calls InitializeAsync again,
which REPLACES InitializationComplete with the mode-load task. Awaiting the handle once therefore
returns while the mode load is still running with the flag legitimately raised — the code was
correct, the assertion was racing. Re-reading the property after the first await yields the second
task; awaiting that too makes it deterministic.

Checked the class, not the instance: swept every VM I touched for the same "assign IsBusy before
the first await" shape. It is present in CpuAffinity, DisplayProfile, Privacy and
NotificationBlocker too, but none of their tests probe the raw constructor (all await init first),
so only Cleanup could fail. Left as-is rather than churning four more files on a pattern nothing
observes; Cleanup is the one with a test pinning the contract.

Verified, not assumed: both cases were added to the runtime harness and now pass against the built
assembly — 43/43. All four projects build with 0 warnings and 0 errors.
…racing the constructor

Second CI failure on the same test, so the previous fix was wrong rather than incomplete.

Task.Yield() before raising the flag was a timing fix for a structural problem. The pre-scan is
fire-and-forget from the constructor, so whether the flag was observable at construction depended
on whether that continuation resumed first — the yield posts to the thread pool, which the harness
lost and CI won. It passed locally and failed on CI for exactly that reason, and it also broke my
own new test, which had asserted the synchronous raise.

Removed the timing dependency instead of adjusting it: PreScanAsync takes an explicit
reportProgress flag. The user-pressed Rescan passes true (a button press must visibly do
something), and the startup scan passes false — its progress is already visible in the "Scanning…"
size labels, which is why nobody missed a bar there. The flag's value at construction is now a
property of the code path, not of scheduler timing.

Found two more call sites the compiler flagged: the re-scans after Temp cleanup and after emptying
the Recycle Bin. Both run while the operation's own flag still holds the bar (it clears in
finally), so both pass false — otherwise the refresh would take the bar over from the operation
that is still running. The finally now only hands the flag back to the derived value when this call
raised it.

Verified across 200 constructions rather than one: a single green run cannot distinguish "fixed"
from "won the race", which is precisely how the first attempt escaped. 200/200 return with the bar
off; the full harness is 43/43 against the built assembly. All four projects build with 0 warnings
and 0 errors.
@laurentiu021
laurentiu021 merged commit 964917c into main Aug 5, 2026
4 checks passed
@laurentiu021
laurentiu021 deleted the fix/service-ledger-history-and-feedback branch August 5, 2026 11:04
laurentiu021 added a commit that referenced this pull request Aug 5, 2026
… chart buffers

CI failed with "Collection was modified; enumeration operation may not execute" thrown from
LiveCharts' CollectionDeepObserver. A real concurrency bug in the history feature I added in #1704,
not a test artifact.

The poll loop appends to _downBuffer/_upBuffer every second while ReloadHistoryAsync rebuilds those
same buffers with ReplaceWith, and LiveCharts observes both. The existing guard tested
ShowingHistory, which is only assigned AFTER the load's await completes — so the window between
entering the reload and that assignment was unguarded. In the app both run on the UI dispatcher and
interleave harmlessly; a test host has no dispatcher, so ConfigureAwait(true) resumes on the thread
pool and the two genuinely overlap. Now gated on a _historyOwnsChart flag claimed BEFORE the first
await and released on the cancellation path, so a cancelled load cannot leave the live chart
permanently frozen.

I was wrong about the cause first. My initial diagnosis was that ReplaceWith let a Reset subscriber
observe the collection mid-rebuild — plausible, and it matched the stack trace's shape, but the
harness disproved it: those assertions passed against the UNFIXED helper, because notification
suppression already means the Reset fires after the rebuild. The stack trace says OnItemsAdded, an
Add notification, which is the poll loop, not the replace. Corrected the fix and the comment rather
than shipping a rationale that reads convincingly and is false.

Investigating it did surface two genuine ReplaceWith defects, both now fixed and pinned: passing the
collection as its own source emptied it (Items.Clear() runs before anything is added back, and the
lazy source was that same collection), and a source that threw part-way left the collection
half-replaced while still bound to the UI. Both are red against the old code and green against the
new one — 20 call sites pass a lazy LINQ query, so the shape was reachable.

What is NOT claimed: the harness does not reproduce the race. 60 reload cycles with the poll loop
live pass against the unfixed code too, so that check is labelled a smoke test, not a red/green
proof. The race has only ever been observed on CI, so CI is where this fix has to be judged.

All four projects build with 0 warnings and 0 errors.
laurentiu021 added a commit that referenced this pull request Aug 5, 2026
…mismatch cannot recur (#1706)

* fix: align the version with the 1.57.0 tag and add a CI guard so the mismatch cannot recur

The v1.57.0 release failed at "Extract release notes from CHANGELOG", leaving a pushed tag with no
published release.

Root cause is mine, not the pipeline's. I titled #1704 feat:, so auto-release computed a MINOR bump
and tagged v1.57.0 — while I had hand-written the csproj version and the CHANGELOG header as
1.56.15, assuming a patch. release.yml then looked for a "## [1.57.0]" section, found none, and
correctly refused rather than publishing a release with placeholder notes. The guard did its job;
the inputs were wrong.

Fixed by moving the version to what the tag actually says: csproj Version/FileVersion/
AssemblyVersion to 1.57.0, and the CHANGELOG header renamed. The notes themselves are unchanged and
already describe exactly what shipped — the feature additions are also what justify a minor bump,
so 1.57.0 is the correct number and 1.56.15 was the mistake. Nothing about the release is
rewritten: the tag stays, and pushing this lets the release be re-dispatched against it.

Turned the failure into a mechanical rule rather than a resolution to remember. CI now cross-checks,
on every PR, that Version/FileVersion/AssemblyVersion agree with each other and that the newest
CHANGELOG header matches — the exact drift that reached a published tag today, caught where it costs
one edit instead of a stuck release. Nothing in the repo verified this before; that absence is why
it got through.

The check was proven, not assumed. Run against three fixtures with the real PowerShell host:
the corrected tree passes; the actual bug (csproj 1.56.15 vs CHANGELOG 1.57.0) exits 1 with
"disagree"; and a partial edit that updates Version but leaves FileVersion behind exits 1 with
"FileVersion (1.56.15.0) does not match Version (1.57.0)". Red before, green after.

* fix: stop the bandwidth poll loop from racing a history reload on the chart buffers

CI failed with "Collection was modified; enumeration operation may not execute" thrown from
LiveCharts' CollectionDeepObserver. A real concurrency bug in the history feature I added in #1704,
not a test artifact.

The poll loop appends to _downBuffer/_upBuffer every second while ReloadHistoryAsync rebuilds those
same buffers with ReplaceWith, and LiveCharts observes both. The existing guard tested
ShowingHistory, which is only assigned AFTER the load's await completes — so the window between
entering the reload and that assignment was unguarded. In the app both run on the UI dispatcher and
interleave harmlessly; a test host has no dispatcher, so ConfigureAwait(true) resumes on the thread
pool and the two genuinely overlap. Now gated on a _historyOwnsChart flag claimed BEFORE the first
await and released on the cancellation path, so a cancelled load cannot leave the live chart
permanently frozen.

I was wrong about the cause first. My initial diagnosis was that ReplaceWith let a Reset subscriber
observe the collection mid-rebuild — plausible, and it matched the stack trace's shape, but the
harness disproved it: those assertions passed against the UNFIXED helper, because notification
suppression already means the Reset fires after the rebuild. The stack trace says OnItemsAdded, an
Add notification, which is the poll loop, not the replace. Corrected the fix and the comment rather
than shipping a rationale that reads convincingly and is false.

Investigating it did surface two genuine ReplaceWith defects, both now fixed and pinned: passing the
collection as its own source emptied it (Items.Clear() runs before anything is added back, and the
lazy source was that same collection), and a source that threw part-way left the collection
half-replaced while still bound to the UI. Both are red against the old code and green against the
new one — 20 call sites pass a lazy LINQ query, so the shape was reachable.

What is NOT claimed: the harness does not reproduce the race. 60 reload cycles with the poll loop
live pass against the unfixed code too, so that check is labelled a smoke test, not a red/green
proof. The race has only ever been observed on CI, so CI is where this fix has to be judged.

All four projects build with 0 warnings and 0 errors.

---------

Co-authored-by: laurentiu021 <laurentiu021@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment