Skip to content

fix: tray notification deadlock and monitor thread busy-spin - #204

Open
shabhui wants to merge 3 commits into
IgorMundstein:mainfrom
shabhui:fix/tray-notification-deadlock-and-monitor-spin
Open

fix: tray notification deadlock and monitor thread busy-spin#204
shabhui wants to merge 3 commits into
IgorMundstein:mainfrom
shabhui:fix/tray-notification-deadlock-and-monitor-spin

Conversation

@shabhui

@shabhui shabhui commented Sep 1, 2026

Copy link
Copy Markdown

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.Update runs on a background monitor thread. It took _disposeLock, then reached a blocking Dispatcher.Invoke via GetIconGetImageIconStart/StopRotationAnimation. Meanwhile the UI thread took that same _disposeLock in OnRotationTimerTick, which fires every 200 ms while optimizing. Each side then waited on the other.

Because Update is called from inside the view model's _lockObject (MainViewModel.cs lines 1717, 1757, 1884), the wedged thread held both locks. MonitorComputer, MonitorApp and Optimize all blocked behind it, and Dispose could never acquire _disposeLock to finish shutting down — which is why the process could only be ended from Task Manager.

TrayIconShowMemoryUsage defaults to false, 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 InvokeOnUi helper. _disposeLock is gone from OnRotationTimerTick.

Supporting thread-safety fixes

  • _iconRenderLock serializes GDI reads of the shared _imageIcon. Removing _disposeLock also removed the incidental serialization of Icon.ToBitmap(), and System.Drawing.Icon is not thread safe.
  • _currentIconLock guards the icon swap so two concurrent callers cannot double-dispose, or leave a disposed icon assigned to the tray.
  • Loading and Notify are marshalled. Both are called from background threads and touch NotifyIcon members that have thread affinity (Visible, ShowBalloonTip).
  • StartRotationAnimation checks _rotationTimer inside the UI callback, so two concurrent starts cannot both create a timer.
  • StopRotationAnimation no longer reads _rotationTimer from the calling thread. That field is UI-thread-owned and not volatile, so a background caller could stale-read null, skip the cleanup and leave the animation running forever.
  • Dispose routes timer cleanup through the UI thread. DispatcherTimer has thread affinity, so Stop() from another thread threw and left the timer firing during shutdown.

The busy-spin

Both monitor loops did this:

if (IsBusy)
    continue;        // skips the WaitOne below

if (token.WaitHandle.WaitOne(60000))
    break;

When IsBusy was set, continue skipped 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: MonitorApp still waits 60 s before its first update, and MonitorComputer still does work-then-delay so the initial memory readout is not postponed.

Also in MainViewModel: the CancellationToken is captured once, and Dispose no longer disposes the CancellationTokenSource while the loops still hold its token — that threw ObjectDisposedException from the while condition, which sits outside the try block and would take down a pool thread.

ViewModel._isBusy is now volatile: written on the UI thread, polled by the background loops.

Other

Checklist

  • My code follows the project's coding style and conventions.
  • I have tested the changes locally.
  • I have updated documentation if necessary — no user-facing behavior change.
  • This PR does not introduce any breaking changes.
  • I have added unit tests if applicable.

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=False occurrences: 0
  • Idle CPU: 0.24 % of one core
  • After each optimization, CPU returns to 0.0 % with no residual spin

Three regression tests added to NotificationServiceTests: concurrent Update from 8 threads, Update racing Dispose, and Loading from 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 _currentIconLock leaves 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." from OnDispatcherUnhandledException, 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. Marshalling Notify (which toggled NotifyIcon.Visible from 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.cs and MainViewModel.cs and lists Closes #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.Compilers 3.11.0, against the restored packages.config set. Worth a CI confirmation.

shabhui 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+.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant