Repository navigation
BackgroundWorker.Dispose() uses the same unbounded WorkerTask.Wait() — latent single-threaded deadlock hazard #5332
Description
Context
Spun off from #5237 / #5330. While fixing the BackpressureMonitor.Dispose() deadlock on single-threaded targets (Unity WebGL / browser-wasm), we noticed BackgroundWorker.Dispose() uses the same unbounded WorkerTask.Wait() pattern and is therefore a candidate for the same hazard.
The pattern
BackgroundWorker starts its worker via Task.Run and parks it on an awaited semaphore wait:
// ctor (line 43)
WorkerTask = Task.Run(DoWorkAsync);
// DoWorkAsync (line 120)
await _queuedEnvelopeSemaphore.WaitAsync(_shutdownSource.Token).ConfigureAwait(false);and Dispose() (lines 328-355) does:
_shutdownSource.Cancel();
// ...
WorkerTask.Wait(); // intentionally unbounded - see the comment in the codeThis is structurally identical to the BackpressureMonitor deadlock in #5237: on a runtime where the thread pool is the single main thread (Unity WebGL / browser-wasm), WorkerTask.Wait() blocks the only thread that could ever run the worker's post-cancellation continuation, so the task can never complete → deadlock. ConfigureAwait(false) does not help, because there is no separate thread for the continuation to escape to.
Note the deliberate comment in Dispose() explaining why no timeout/token is passed to Wait() ("we are waiting for the continuation of the method, not its execution"), so any fix needs to preserve the queue-draining/flush semantics.
Reachability — why this is latent, not the cause of #5237
In the current SDK, BackgroundWorker.Dispose() does not appear to be reached by the normal SentrySdk.Close() / Hub.Dispose() shutdown path:
Hub.Dispose()flushes viaCurrentClient.FlushAsync(_options.ShutdownTimeout)— bounded byShutdownTimeout— and never disposes the client or the worker.SentryClient.Dispose()also only callsWorker.FlushAsync(...); it never callsWorker.Dispose().SentryClient.Workeris typed as the publicIBackgroundWorker, which does not extendIDisposable; only the internal concreteBackgroundWorkerdoes.
So the unbounded WorkerTask.Wait() seems only reachable from tests or from code that constructs/disposes a BackgroundWorker directly. This is consistent with the #5237 reporter's ablation: setting EnableBackpressureHandling = false fully resolved the Unity WebGL freeze, which implies the only unbounded Wait() actually hit during their shutdown was BackpressureMonitor.Dispose() — not the background worker.
Why file it anyway
#5237 showed how easily this bites: the monitor's deadlock was live in 4.x/5.x and only incidentally stopped being reachable in 6.0.0 (when an unrelated change removed the Hub.Dispose() → _backpressureMonitor?.Dispose() call). The same could happen here in reverse — a future change that disposes the worker during shutdown would silently reintroduce a single-threaded deadlock.
Proposed action
- Confirm whether
BackgroundWorker.Dispose()can be reached on single-threaded targets (Unity WebGL, Blazor WASM) in any supported configuration. - If so (or defensively regardless), make
Dispose()not block the calling thread onWorkerTaskon single-threaded runtimes — applying the same non-blocking approach used forBackpressureMonitorin fix: BackpressureMonitor.Dispose() no longer deadlocks on single-threaded targets #5330, while preserving the flush/drain semantics the current unboundedWait()guarantees.
Activity
linear-code commentedon Jun 30, 2026
NET-717
Flash0ver commentedon Jul 1, 2026
thought: I think that, alongside
Task.Wait(), we should also scan forTask.Result(inTask<T>), which also synchronously blocks until the task is completed in order to return the result syncrhonously ... should the task already be completed, then there is no blocking as aTaskonly competes once.