Fix/audit reliability hardening - #46
Conversation
Four defects that were visible to users, the first of which could destroy saved configuration: - PersistentProcessRuleJsonStore cached the result of a failed read. A transient lock from antivirus, a backup agent or a sync client left an empty rule set pinned for the rest of the session, and the next save then overwrote every saved rule with it. A failed read is no longer cached, and the unreadable file is preserved once as *.unreadable so the data stays recoverable even if a later save succeeds. - SettingsViewModel discarded the user's unsaved edits whenever any background write touched settings, for example the startup update check recording LastUpdateCheckUtc. While edits are pending only the saved snapshot is re-based now. - The quiet-hours comparison in SmartNotificationService was inverted relative to its own comments: the shipped 22:00-08:00 default never suppressed anything, and a daytime window suppressed everything around the clock. An explicit Do Not Disturb request was also overridden by the schedule. Extracted IsWithinQuietHours with correct wrap-around handling, gave the explicit request precedence, and made auto-expiry raise DoNotDisturbChanged. - Default hotkeys sat behind a null check on settings.KeyboardShortcuts, which is never null because the model initialises an empty list and CopyFrom keeps it non-null, so a fresh install registered no global hotkeys at all. The condition now tests Count. Also fixes the notification retry ladder, which was unreachable behind a hardcoded success flag, and the throttle history race between caller threads, the processing timer and the hourly cleanup timer. SettingsViewModel gains its OnDispose override here rather than in the disposal commit, to keep the file's changes in one place.
The service container was never disposed. Disposing it is what flushes buffered structured logs, stops the WMI watchers and releases native performance counters, so the last few seconds of diagnostics before every exit were lost. ProcessMonitorService.Dispose set the disposed flag before calling StopMonitoringAsync, which short-circuits on that flag, so Dispose was a guaranteed no-op and left the WMI watchers and the polling timer running. Idempotency is now tracked separately from the disposed flag, and the semaphore call sites are guarded against ObjectDisposedException. PowerPlanViewModel now releases its ten-second refresh timer. LogViewerViewModel and ProcessPowerPlanAssociationViewModel detach from the singleton services they subscribed to, so a closed window no longer keeps handlers alive for the rest of the process. The two async void handlers in SettingsWindow are wrapped, so a failure there reports instead of tearing down the process.
MainWindow.OnClosing cancels the close and then runs the shutdown chain. A fault anywhere in that chain left the window permanently unclosable with no visible error, because the close had already been cancelled. It now runs through TaskSafety with a fallback Shutdown(). The tray context-menu rebuild mutates Windows Forms ToolStrip state from a powercfg continuation, that is, a thread-pool thread. UpdateContextMenuAsync now takes a dispatcher and marshals every tray call, and monitoring-status updates raised from the WMI watcher and polling-timer threads are dispatched too. The tray power-plan entry could also go stale. It refreshes from the existing IPowerPlanService.PowerPlanChanged notification rather than by re-enabling the periodic tray timer: that timer is gated off by ShowAdvancedDiagnostics, and the gating is what produced the idle-CPU reduction recorded for v1.4.4. OnClosed also disposes the view models the window owns, which is what the disposal overrides in the previous commit exist for. ProcessViewModel and MasksViewModel are singletons and are left to the container.
PerformanceMonitoringService had four problems: historicalData was mutated without a lock, a second StartMonitoringAsync could install a duplicate timer, the core-counter list could be replaced while a tick was iterating it, and disposal released the counters before marking state so an in-flight tick could resurrect them. It now locks the history, guards the start with Interlocked, snapshots the counter list, and marks state before releasing anything. The first tick is delayed one second instead of firing immediately. WMI result collections and objects are disposed. Per-core counters only ever covered the first 64 logical processors, because the legacy Processor category cannot express processor groups. Above that limit the group-aware Processor Information category is used, and a missing individual instance no longer clears the whole per-core view. A failed CPU Set topology probe marked the mapping initialised, so one transient failure disabled CPU Sets for the entire process lifetime. The initialised flag is now only set on success, with a thirty-second backoff between retries. The failure path still reports CpuSetsUnavailable and callers still fall back exactly as before. EnhancedLoggingService.IsDebugLoggingEnabled deep-cloned the settings model on every structured log call, because the settings accessor clones. The flag is cached and kept current from SettingsChanged instead.
EnableAutostartAsync removed the legacy HKCU Run entry before checking whether it could create the scheduled task. Without elevation it deleted the mechanism that was working and then bailed out, leaving the user with no autostart while the UI still reported it enabled. Elevation is now checked first, and the legacy entry is retired only once the scheduled task is confirmed created. DisableAutostartAsync removes the task before the legacy entry, and UpdateAutostartAsync calls enable directly, relying on schtasks /F to overwrite, instead of a disable-then-enable sequence that had no rollback. The requireAdministrator manifest, the startup elevation bootstrap and the scheduled-task definitions are unchanged.
Adds an Unreleased section covering the data-loss, lifecycle and threading fixes, with explicit Safety notes confirming the elevation model, the protected-process denylist, the priority guardrails and the affinity apply pipeline are unchanged.
Keeps scratch notes and draft documents under /tmp out of the working tree status, alongside the other local-only artifact patterns.
PrimeBuild-pc
left a comment
There was a problem hiding this comment.
Thank you very much for taking the time to audit the project and submit such a detailed contribution. I appreciate the clear commit structure, the changelog update, the risk notes, the added tests, and the fact that you explicitly documented the UI flows that were not manually validated. A substantial part of this PR is heading in the right direction, and I did not find suspicious dependencies, unrelated workflow changes, weakened security controls, or other signs of malicious behavior.
After reviewing the full diff, however, I found a few correctness issues that I need addressed before I can approve the PR.
1. PersistentProcessRuleJsonStore can still overwrite an unpreserved rules file
The new behavior is intended to protect persistent_rules.json after a failed read, but the protection is not guaranteed in the transient-lock scenario described in the PR.
The current sequence is:
LoadAsyncfails and setsloadFailed = true.TryPreserveUnreadableFileattempts to copy the original file to.unreadable.- If that copy also fails, the exception is only logged at debug level.
- A later
SaveAsyncis still allowed to replace the original file.
If antivirus, backup, or synchronization software temporarily locks the file, both the read and the backup copy can fail for the same reason. Once the lock is released, a subsequent save can replace the original file even though no recovery copy was successfully created.
There is a second edge case: if an older .unreadable file already exists, the method returns without preserving the current content. A newer damaged or temporarily unreadable rules file could therefore still be lost.
Please change this so that a save after a failed read cannot overwrite the existing file unless the existing content has actually been preserved. A timestamped or otherwise unique recovery filename would also avoid reusing a stale backup.
Please add tests covering at least:
- preservation failure followed by a save attempt;
- an existing
.unreadablerecovery file; - retrying after a transient read/copy failure;
- confirmation that the original file is not overwritten when preservation was unsuccessful.
The current corrupt-JSON test validates a readable but invalid file; it does not cover the file-lock/preservation-failure path described by the PR.
2. The Settings rebase is not a true merge and can overwrite background updates
The new OnSettingsServiceSettingsChanged behavior correctly avoids immediately replacing the user's visible unsaved edits. However, while edits are pending, it updates only savedSettingsSnapshot and leaves the complete Settings object unchanged.
SaveSettingsAsync later writes the entire Settings object back through UpdateSettingsAsync, rather than applying only the properties changed by the user.
For example:
- The user changes a UI setting but does not save it.
- A background update changes
LastUpdateCheckUtc. - The persisted snapshot is updated, but the UI model still contains the old
LastUpdateCheckUtcvalue. - The user saves the unrelated UI change.
- The old timestamp is written back, undoing the background update.
This prevents background writes from deleting the user's edits, but it creates the inverse problem: saving the user's edits can delete background changes.
Please implement a real three-way merge using:
- the previous saved snapshot;
- the newly persisted settings;
- the locally edited settings;
Only properties actually changed locally should override the newly persisted model. Tracking a local dirty-property set would also be a reasonable approach.
Please add a regression test where a user-facing property is edited locally, an unrelated property such as LastUpdateCheckUtc changes externally, and saving preserves both changes.
3. Skipping an unavailable CPU counter loses the logical processor identity
The per-core counter initialization now skips individual unavailable instances, which is preferable to dropping the entire per-core view. However, the resulting counters are stored in a compact list, and GetCpuCoreUsageAsync uses the list index as the logical CoreId and as the input for topology lookups.
If logical processor 2 is unavailable but processor 3 is available, the counter for processor 3 becomes list element 2 and is reported and classified as core 2. This can produce incorrect core names, core types, physical-core mappings, and hyper-threading information.
Please preserve the original logical processor index together with each counter, for example with a small record/tuple containing (LogicalProcessorIndex, PerformanceCounter), and use that index when producing CpuCoreUsage and querying topology.
Please add a test or an injectable abstraction that verifies correct IDs when an intermediate counter instance is unavailable.
4. Please verify the performance-monitor stop/start race
StopMonitoringAsync resets isMonitoringTickInProgress immediately after disposing the timer, but disposing a System.Threading.Timer does not necessarily wait for an already-running async callback to finish. A rapid stop/start can therefore allow the old callback and the new timer callback to overlap while sharing counters and historical state.
Please either harden this lifecycle so an old generation cannot overlap a restarted monitor, or add a deterministic test demonstrating that the current implementation is safe. A generation token, cancellation token, or awaited shutdown mechanism would make the intended lifecycle clearer.
5. Additional validation required
The PR currently reports local build/test results, while the repository CI and CodeQL runs are still waiting for authorization and have not produced jobs. Before approval, I need the repository checks to run and pass, including build, tests, coverage, dependency audit, secret scanning, and CodeQL.
Please also manually validate the elevated Windows UI flows that the PR itself identifies as not yet tested:
- tray menu rebuilding and power-plan checkmark refresh;
- monitoring-status updates from background threads;
- default and configured global hotkey registration;
- quiet-hours and explicit/timed Do Not Disturb behavior;
- normal shutdown and fallback shutdown after an exception;
- autostart enable, update, and disable behavior.
I would also appreciate focused regression coverage for the autostart ordering, notification retry path, debug-logging cache updates, and complete shutdown/disposal chain, because these are meaningful behavior changes that currently rely mostly on inspection.
Scope
This PR is considerably broader than the repository's single-concern guideline. I am not requiring a split if the issues above are corrected cleanly and the validation remains manageable, but I am open to reviewing the smaller sequence proposed in the PR description if that is easier.
Again, thank you for the contribution. There are several worthwhile fixes here, and I would be happy to re-review the PR after these points are addressed and the checks are green.
Summary
Fixes a set of defects found by an audit of the code outside the affinity/topology/rules core:
four correctness bugs that are visible to users (one of them a data-loss path), five lifecycle
bugs, and a group of threading problems around the tray, notifications and performance
monitoring.
No behaviour in the elevation model, the process-control guardrails or the affinity apply
pipeline is changed. See Security and Risk Notes below.
Branch:
fix/audit-reliability-hardening, seven commits split by concern:3a8e74ffix:data loss and correctness — rules store, settings edits, quiet hours, hotkey defaults7d47802fix:disposal — service container,ProcessMonitorService, view models1488cdbfix:main window close handling and tray thread affinity42b8f8afix:performance monitoring and CPU Set probing300fe4afix:autostart orderingd647aaddocs:changelogb7d31eachore:ignore the localtmpworking directoryReviewing commit by commit works; each one is a single concern and the tip is what was built and tested.
The changed code carries no explanatory comments. The rationale for each change lives in the commit
message and in
docs/CHANGELOG.mdinstead, so it stays in one place rather than being duplicatedinto the source. Existing comments in the touched files are untouched.
Type of Change
What changed
Data safety and correctness
persistent_rules.json(antivirus/backup/sync lock) cached an empty rule set for the session; the next save then overwrote every saved rulePersistentProcessRuleJsonStoreno longer caches a failed read, and preserves an unreadable file as*.unreadablebefore any later save can replace itLastUpdateCheckUtc) overwrote the user's unsaved Settings edits and cleared the dirty flagSettingsViewModelre-bases only the saved snapshot while edits are pendingIsWithinQuietHourswith correct wrap-around handling; an explicit request now wins over the schedule; auto-expiry raisesDoNotDisturbChangedsettings.KeyboardShortcuts != null, which is never false because the model initialises an empty list andCopyFromkeeps it non-null, so a fresh install registered no global hotkeysCount > 0; registration is refused (and logged) when no window handle exists yet;SetWindowHandleis idempotentLifecycle
ProcessMonitorService.Disposeset the disposed flag before callingStopMonitoringAsync, which short-circuits on that flag, so nothing was stopped. The idempotency flag is now separate from the disposed flag.MainWindow.OnClosingcancels the close and then runs the shutdown chain. A fault there left the window permanently unclosable with no visible error; it now runs throughTaskSafetywith a fallbackShutdown().PowerPlanViewModelreleases its 10-second timer;LogViewerViewModel,ProcessPowerPlanAssociationViewModelandSettingsViewModeldetach from the singleton services they subscribed to.KeyboardShortcutService.Disposeno longer blocks on a Task, and a failedUnregisterHotKeyno longer leaves a stale entry that makes "clear all" report success.Threading
ToolStripstate from apowercfgcontinuation, i.e. a thread-pool thread.UpdateContextMenuAsyncnow takes the dispatcher and marshals every tray call; monitoring-status updates raised from WMI watcher and polling-timer threads are dispatched too.SmartNotificationServiceis aList<DateTime>inside aConcurrentDictionary, written by caller threads, the 2-second processing timer and the hourly cleanup timer. It is now lock-guarded and usesGetOrAddinstead of check-then-act.PerformanceMonitoringService:historicalDatais lock-guarded, duplicate timers are prevented withInterlocked, the core-counter list is snapshotted before iteration, and disposal marks state before releasing counters so an in-flight tick cannot resurrect them.Other
Processor Informationcategory above 64 logical processors, and tolerate individual missing instances instead of clearing the whole per-core view.success = true).EnhancedLoggingServicecaches the debug-logging flag instead of deep-cloning the ~60-property settings model on every structured log call.UpdateAutostartAsyncno longer disables before enabling.Validation
Run locally on SDK 8.0.423 (pinned by
global.jsonat8.0.400/latestFeature):The first Debug build reported one warning, SA1623 on the new
IsDebugLoggingEnabledsummary; thatis fixed in
2dc793aand both configurations are now warning-free.Coverage gate from
ci-devsecops.ymlreproduced locally with the same coverlet runsettings:UI flows are the one box still unchecked. The app ships a
requireAdministratormanifest, andthe paths this PR touches most — the tray context menu rebuild, quiet-hours suppression, global
hotkey registration and the shutdown/disposal chain — need an interactive elevated session with real
processes and power plans to exercise meaningfully. They are covered by unit tests at the logic level
(quiet-hours wrap-around, hotkey defaults, dispatcher marshalling,
Disposeactually stoppingmonitoring) but were not clicked through by hand.
Other checks that passed locally:
.githooks/pre-commit.ps1applied to every committed file: no forbidden patterns or directories, nothing over 100 MB.TestResults/,bin/andobj/are all confirmed ignored, so the coverage run left nothing behind.Security and Risk Notes
Required by
docs/CONTRIBUTING.mdbecause this touches elevation, process control and power plans.Elevation —
AutostartService.EnableAutostartAsyncnow checksIsRunningAsAdministrator()before removing the legacy
HKCU\...\Runentry, and only retires that entry once the scheduledtask is confirmed created. The previous order deleted the working mechanism and then bailed out,
leaving a user with no autostart while the UI still showed it enabled.
UpdateAutostartAsynccallsenable directly, relying on
schtasks /Fto overwrite, instead of a disable-then-enable sequencethat had no rollback. The
requireAdministratormanifest, the startup elevation bootstrap and thescheduled-task definitions are unchanged.
Process control — no change to how affinity, priority or memory priority are applied. The
CpuSelectionAffinityApplierorder (CPU Sets first, legacy fallback only when representable), theRealtime priority block, the High priority warning, the protected-process denylist and the
NtQueryInformationProcesscheck are all untouched. The one behavioural change in this area isthat a failed CPU Set topology probe is retried after 30 seconds instead of being cached as
permanently unavailable; the failure path itself still reports
CpuSetsUnavailableand callersstill fall back exactly as before.
ProcessMonitorService.Disposenow genuinely stops monitoring,which is a behaviour change on exit paths that previously leaked the watchers.
Power plans — no new
powercfgcalls and no change toPowerPlanService,PowerPlanTransitionGateor the association engine. The tray subscribes to the existingIPowerPlanService.PowerPlanChangednotification to refresh its menu. The periodic tray timer wasdeliberately not re-enabled: it is gated off by
AppNavigationOptions.ShowAdvancedDiagnostics,and that gating is what produced the idle-CPU reduction recorded for v1.4.4, so the fix is
event-driven instead of restoring polling.
Persisted configuration — no schema or format change.
persistent_rules.json,settings.json,core_masks.jsonand saved profiles are read and written exactly as before. Theonly new file the app can create is a
persistent_rules.json.unreadablecopy, written once, onlywhen the real file cannot be parsed, and never overwritten.
Regressions to watch during review
Dispose.ProcessMonitorManagerService.Disposeblocks onStopAsyncbehind a semaphore; on the normal pathPerformGracefulShutdownAsynchas already stopped it so this returns immediately, but a shutdown that skips the graceful path will now do that work at exit.OnClosedmeans their handlers stop firing earlier than before.ProcessViewModelandMasksViewModelare singletons and are intentionally left to the container.UpdateContextMenuAsyncgained a required parameter. There is a single production call site and the test was updated.Checklist
docs/CHANGELOG.mdgains an Unreleased section. The repo has no priorUnreleasedconvention, so please rename it to the target version at release time.Tests added
SmartNotificationQuietHoursTests(15 cases)KeyboardShortcutDefaultsTestsPersistentProcessRuleJsonStoreTests(+2)ProcessMonitorServiceSettingsTests(+2)Disposeactually stops monitoring, and is idempotentSystemTrayStatusUpdaterTests(+1)Note on scope
docs/CONTRIBUTING.mdasks for pull requests scoped to a single concern, and this one is not: itcarries 17 defect groups across 13 production files. They were found and fixed as one audit pass.
If you would prefer smaller reviews, a clean split would be:
PersistentProcessRuleJsonStore,SettingsViewModel,SmartNotificationService(quiet hours),KeyboardShortcutService(defaults) + their testsApp.xaml.cs,ProcessMonitorService,MainWindow(OnClosing/OnClosed), the ViewModel disposal overrides + their testsSystemTrayStatusUpdater,MainWindowtray paths + its testPerformanceMonitoringService,ProcessCpuSetHandler,EnhancedLoggingServiceAutostartServiceHappy to reopen it as that sequence if you want it reviewed in pieces.