Skip to content

fix: BackpressureMonitor.Dispose() no longer deadlocks on single-threaded targets#5330

Open
jamescrosswell wants to merge 8 commits into
mainfrom
fix/5237-backpressure-dispose-deadlock
Open

fix: BackpressureMonitor.Dispose() no longer deadlocks on single-threaded targets#5330
jamescrosswell wants to merge 8 commits into
mainfrom
fix/5237-backpressure-dispose-deadlock

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #5237

Problem

On single-threaded runtimes (Unity WebGL / browser-wasm) there is no separate thread pool — Task.Run and Task.Delay continuations all run on the one main thread. BackpressureMonitor.Dispose() did:

_cts.Cancel();
_workerTask.Wait();   // blocks the only thread

_workerTask.Wait() synchronously blocks the calling thread waiting for the worker's post-cancellation continuation, which on a single-threaded runtime can only be scheduled on that same blocked thread (e.g. calling unityInstance.Quit() in Unity WebGL).

ConfigureAwait(false) does not help here. On a desktop host the cancellation continuation escapes to a real thread-pool thread. On single-threaded targets the "thread pool" is the main thread, so the continuation has nowhere to run except the thread that is blocked in Wait().

Fix

Dispose() now only requests cancellation and returns — it does not block on the worker. The worker observes the token, exits its Task.Delay loop and unwinds on its own; it produces no result anyone needs to await.

Because the monitor's public methods (DownsampleFactor / RecordQueueOverflow / RecordRateLimitHit) never touch _cts, it is now safe to dispose the monitor even while the client keeps draining envelopes. This lets Hub.Dispose() dispose the monitor again rather than leaking the worker for the process lifetime.

⚠️ Hub.Dispose()

The call to _backpressureMonitor?.Dispose() was removed in #4613 with the comment:

Don't dispose … without throwing an ObjectDisposedException

I've restored it because, with a non-blocking Dispose() that only cancels/disposes the CTS, none of the monitor's post-disposal call sites touch the disposed token source — so the original ObjectDisposedException should not recur.

Warning

TODO: Confirm this matches the scenario #4613 was guarding against. We can always reinstate the fix from #4613 if we're concerned about this.

Affected versions

The live deadlock path (Hub.Dispose()_backpressureMonitor?.Dispose()) exists in 4.x/5.x (and the Sentry Unity 4.x that reported this). It was incidentally removed on main/6.0.0+ by #4613, so 6.x no longer reaches the deadlock - but at the cost of leaking the worker. Dispose() itself remained latently broken. This PR fixes the root cause so the call is safe again. Users on ≤5.x operating in a single threaded environment should set EnableBackpressureHandling = false as a workaround instead.

🤖 Generated with Claude Code

…aded targets

On single-threaded runtimes (Unity WebGL / browser-wasm) there is no separate
thread pool: Task.Run and Task.Delay continuations all run on the one main
thread. BackpressureMonitor.Dispose() called _cts.Cancel() followed by a
synchronous _workerTask.Wait(), which blocked the only thread that could run
the worker's cancellation continuation - a deadlock. On Unity WebGL this wedged
unityInstance.Quit() indefinitely (#5237).

Dispose() now only requests cancellation and returns; the worker observes the
token and unwinds on its own. The monitor's public methods (DownsampleFactor /
RecordQueueOverflow / RecordRateLimitHit) never touch the cancellation token
source, so it is now safe for Hub.Dispose() to dispose the monitor again rather
than leaking the worker for the lifetime of the process.

Adds a regression test that runs the worker on a scheduler that never completes
and asserts Dispose() returns without blocking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.21%. Comparing base (0061920) to head (35ece53).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5330      +/-   ##
==========================================
+ Coverage   74.16%   74.21%   +0.04%     
==========================================
  Files         508      508              
  Lines       18353    18363      +10     
  Branches     3586     3589       +3     
==========================================
+ Hits        13612    13628      +16     
+ Misses       3869     3863       -6     
  Partials      872      872              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/Sentry/Internal/BackpressureMonitor.cs Outdated
Comment thread src/Sentry/Internal/Hub.cs Outdated
Co-authored-by: James Crosswell <jamescrosswell@users.noreply.github.com>
@jamescrosswell jamescrosswell marked this pull request as ready for review June 29, 2026 23:53
Comment thread src/Sentry/Internal/BackpressureMonitor.cs
jamescrosswell and others added 2 commits June 30, 2026 12:05
…rker stops

Removing the blocking _workerTask.Wait() from Dispose() (to avoid the
single-threaded deadlock) reintroduced a race: the inline _cts.Dispose() could
run while the worker was still inside Task.Delay(..., token) registering its
continuation. On platforms where registering on a disposed source throws (e.g.
.NET Framework) the worker would observe an ObjectDisposedException - which it
doesn't catch - and fault with an unobserved task exception.

Dispose the CancellationTokenSource from a non-blocking continuation on the
worker task instead, so it is only disposed once the worker has stopped using
the token. Adds a test asserting the worker runs to completion without faulting
after Dispose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…adlock' into fix/5237-backpressure-dispose-deadlock
Comment thread src/Sentry/Internal/BackpressureMonitor.cs
Comment thread src/Sentry/Internal/BackpressureMonitor.cs Outdated
jamescrosswell and others added 3 commits June 30, 2026 12:19
A second Dispose() call would hit the already-disposed _cts and log a spurious
ObjectDisposedException warning (and schedule redundant cleanup). Guard with a
thread-safe Interlocked flag so only the first caller runs the disposal logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etsentry/sentry-dotnet into fix/5237-backpressure-dispose-deadlock
Comment thread src/Sentry/Internal/BackpressureMonitor.cs Outdated
Comment thread src/Sentry/Internal/BackpressureMonitor.cs Outdated
_cts,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be passing TaskScheduler.Default here or TaskScheduler.Current? In production it doesn't really matter, since those two things will always be the same. They are only ever different if/when we explicitly pass in a non default scheduler at task creation time... for us, that only happens when running unit tests. So if we used TaskScheduler.Current, I think we'd end up using the Scheduler injected for unit testing purposes here as well (not just when creating the Task above). It probably doesn't really matter since I don't think we have any concurrency problems with this code (it's just disposing of the cts synchronously once the task completes) but for the sake of consistency, maybe it's better to use TaskScheduler.Current here?

@jamescrosswell jamescrosswell Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TaskScheduler.Current wouldn't pick up the injected scheduler anyway. Current is evaluated at the point ContinueWith is called — i.e. inside Dispose(), on whatever thread calls it. In the tests Dispose() is invoked from the test thread (or a plain Task.Run), not from within a task running on the injected scheduler, so Current resolves to Default there too. The injected scheduler only governs the worker task's own execution, not this continuation. So Current vs Default makes no observable difference in our tests.

…Factory.StartNew

Use one code path for production and tests: Task.Factory.StartNew with a
swappable scheduler (defaulting to TaskScheduler.Default) and
TaskCreationOptions.DenyChildAttach, exactly matching the previous
Task.Run(() => DoWorkAsync(_cts.Token)). The outer task-creation token stays
CancellationToken.None (as Task.Run uses) so the worker is never cancelled
before it starts. Only the scheduler differs, injected by tests to model
single-threaded runtimes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Jul 1, 2026
@jamescrosswell jamescrosswell requested a review from Flash0ver July 1, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BackpressureMonitor.Dispose() deadlocks on single-threaded targets (Unity WebGL)

2 participants