fix: tray notification deadlock and monitor thread busy-spin - #204
Open
shabhui wants to merge 3 commits into
Open
fix: tray notification deadlock and monitor thread busy-spin#204shabhui wants to merge 3 commits into
shabhui wants to merge 3 commits into
Conversation
added 3 commits
September 1, 2026 16:38
The app could freeze in a state where the window stopped responding and the process could only be ended from Task Manager. Root cause is a lock-ordering (AB-BA) deadlock around the tray icon. NotificationService.Update ran on a background monitor thread, took _disposeLock, and then reached a blocking Dispatcher.Invoke by way of GetIcon -> GetImageIcon -> Start/StopRotationAnimation. Meanwhile the UI thread took the same _disposeLock in OnRotationTimerTick, which fires every 200ms while optimizing. Each side then waited on the other. Because Update is called inside the view model's _lockObject, the wedged thread held both locks, so MonitorComputer, MonitorApp and Optimize all blocked behind it, and Dispose could never acquire _disposeLock to exit. That is what made the process unkillable by normal means. The default TrayIconShowMemoryUsage=false routes through the rotation animation, so the default configuration is the affected one. Changes: - NotificationService: render the icon outside all locks and marshal only the assignment to the UI thread through a new non-blocking InvokeOnUi helper. Remove _disposeLock from OnRotationTimerTick. Add _iconRenderLock to serialize GDI reads of the shared _imageIcon, and _currentIconLock to guard the icon swap so concurrent callers cannot double-dispose. Marshal Loading and Notify, which are called from background threads and touch NotifyIcon members that have thread affinity. Move the timer null check inside the UI callback in StartRotationAnimation so two concurrent starts cannot both create a timer, and drop the unsynchronized _rotationTimer read in StopRotationAnimation that could stale-read null and leave the animation running. - Dispose: DispatcherTimer has thread affinity, so Stop() from another thread threw and left the timer live during shutdown. Route cleanup through the UI thread. - MainViewModel: the monitor loops skipped their WaitOne when IsBusy was set, turning both into busy-spins that pinned a core for the duration of an optimization. Run the delay unconditionally. Capture the CancellationToken once, and stop disposing the CancellationTokenSource while loops still hold its token, which threw ObjectDisposedException from the loop condition outside the try block. - ViewModel: mark _isBusy volatile. It is written on the UI thread and polled by the background loops. - MainWindow: replace Thread.Sleep(1000) on the UI thread with a DispatcherTimer so the grace period no longer freezes the window. - App: add a shutdown watchdog that force-exits if a graceful shutdown stalls, and marshal Application.Shutdown to the UI thread. Adds three regression tests covering concurrent Update calls, Update racing Dispose, and Loading from a background thread.
Icon rendering used to be serialized incidentally, because Update held _disposeLock for its whole duration. Removing that lock to break the deadlock also removed the serialization, and the replacement _iconRenderLock only covered GetRotatedIcon. That left a half-covered invariant: rendering looks locked, but only one of the two paths is. GetMemoryUsageIcon is reachable concurrently from the monitor threads and creates its GDI objects per call, so it is safe as written today. It is covered anyway, so that caching a Font, Brush or StringFormat in a field later cannot silently introduce a data race.
The token-capture fix in RC1 made disposal safe: loops capture the token once into a local at entry, and CancellationToken.IsCancellationRequested stays usable after the source is disposed (it's a struct). Before RC1, the loop read _cancellationTokenSource.Token fresh in the while condition, which could throw ObjectDisposedException after disposal on a pool thread outside the try block. With the capture, disposing is safe and clears the CA2213 analyzer warning that fails CI builds with /warnaserror+.
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.
Summary
Fixes a lock-ordering (AB-BA) deadlock around the tray icon that leaves the window unresponsive and the process unable to exit, plus a busy-spin in both background monitor loops.
This addresses the freeze described in #183 / #190 (GUI unresponsive, cannot exit from the tray, only Task Manager works). Please see the note at the bottom — I do not believe it explains every report in those threads.
Related Issue
Relates to #183, #190, #192
Changes
The deadlock
NotificationService.Updateruns on a background monitor thread. It took_disposeLock, then reached a blockingDispatcher.InvokeviaGetIcon→GetImageIcon→Start/StopRotationAnimation. Meanwhile the UI thread took that same_disposeLockinOnRotationTimerTick, which fires every 200 ms while optimizing. Each side then waited on the other.Because
Updateis called from inside the view model's_lockObject(MainViewModel.cslines 1717, 1757, 1884), the wedged thread held both locks.MonitorComputer,MonitorAppandOptimizeall blocked behind it, andDisposecould never acquire_disposeLockto finish shutting down — which is why the process could only be ended from Task Manager.TrayIconShowMemoryUsagedefaults tofalse, and that default routes through the rotation animation, so the default configuration is the affected one.Fix: render the icon outside all locks and marshal only the assignment to the UI thread through a new non-blocking
InvokeOnUihelper._disposeLockis gone fromOnRotationTimerTick.Supporting thread-safety fixes
_iconRenderLockserializes GDI reads of the shared_imageIcon. Removing_disposeLockalso removed the incidental serialization ofIcon.ToBitmap(), andSystem.Drawing.Iconis not thread safe._currentIconLockguards the icon swap so two concurrent callers cannot double-dispose, or leave a disposed icon assigned to the tray.LoadingandNotifyare marshalled. Both are called from background threads and touchNotifyIconmembers that have thread affinity (Visible,ShowBalloonTip).StartRotationAnimationchecks_rotationTimerinside the UI callback, so two concurrent starts cannot both create a timer.StopRotationAnimationno longer reads_rotationTimerfrom the calling thread. That field is UI-thread-owned and not volatile, so a background caller could stale-readnull, skip the cleanup and leave the animation running forever.Disposeroutes timer cleanup through the UI thread.DispatcherTimerhas thread affinity, soStop()from another thread threw and left the timer firing during shutdown.The busy-spin
Both monitor loops did this:
When
IsBusywas set,continueskipped the delay, so the loop spun as fast as the CPU allowed for the whole duration of an optimization. The delay now runs unconditionally. Original timing is preserved:MonitorAppstill waits 60 s before its first update, andMonitorComputerstill does work-then-delay so the initial memory readout is not postponed.Also in
MainViewModel: theCancellationTokenis captured once, andDisposeno longer disposes theCancellationTokenSourcewhile the loops still hold its token — that threwObjectDisposedExceptionfrom thewhilecondition, which sits outside thetryblock and would take down a pool thread.ViewModel._isBusyis nowvolatile: written on the UI thread, polled by the background loops.Other
MainWindow.OnOptimizeCommandCompleted:Thread.Sleep(1000)on the UI thread replaced with aDispatcherTimer, so the grace period no longer freezes the window. Note: this duplicates part of fix: resolve GUI responsiveness and window state issues #184. I kept it so this PR stands alone, but happy to drop it if fix: resolve GUI responsiveness and window state issues #184 lands first.App.Shutdownis marshalled to the UI thread and arms an 8 s watchdog that force-exits if a graceful shutdown stalls. Defensive only.Checklist
Testing
Built Release and ran the suite: 342 tests, 0 failures, 0 errors (up from 339; three added).
Runtime check on the patched build, 227 s sample window with several manual optimizations:
Responding=Falseoccurrences: 0Three regression tests added to
NotificationServiceTests: concurrentUpdatefrom 8 threads,UpdateracingDispose, andLoadingfrom a background thread.Limits of these tests, stated plainly: they guard against hangs, exceptions and reintroduced blocking, but they do not reproduce the original deadlock — that needs a real
WpfApplication, which can only be constructed once per process and would pollute the suite. I also verified they do not catch the icon-swap race: reverting_currentIconLockleaves them all passing. That lock is reasoned hardening, not test-proven.Additional Notes
I don't think this closes #183 entirely. Several comments there report a different signature:
"Not enough quota is available to process this command."fromOnDispatcherUnhandledException, and the tray icon disappearing while the process keeps running. That reads like USER-object or desktop-heap exhaustion rather than a deadlock, and a deadlocked UI thread would not produce a dispatcher exception. MarshallingNotify(which toggledNotifyIcon.Visiblefrom pool threads) may help, but I have not reproduced or confirmed that mechanism, so I'd treat it as an open, separate defect.I also noticed #195 touches
NotificationService.csandMainViewModel.csand listsCloses #183. As far as I can tell its changes are caching and lock granularity and do not break the_disposeLock↔ dispatcher cycle above, so these may be complementary rather than competing — but you'll know better than I do. Happy to rebase on whichever lands first.Environment note: no Visual Studio on my machine, so I built with MSBuild v4 (for the WPF XAML targets) plus
Microsoft.Net.Compilers3.11.0, against the restoredpackages.configset. Worth a CI confirmation.