Skip to content

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

Description

@ruslan-kodable

Package

Sentry

.NET Flavor

IL2CPP

.NET Version

2.1 (Unity 2021.3.x)

OS

Browser

OS Version

Chrome 148.0.7778.97 (Official Build) (arm64) MacOS 26.4.1

Development Environment

Rider 2025.x (macOS)

Other Error Monitoring Solution

No

Other Error Monitoring Solution Name

No response

SDK Version

4.3.1

Self-Hosted Sentry Version

No response

Workload Versions

Unity 2021.3.24f1
emscripten (whatever ships with Unity 2021.3.x WebGL — 2.0.x range)
Build settings: IL2CPP, WebGL exception support ExplicitlyThrownExceptionsOnly, Brotli compression
react-unity-webgl v10 on the JS side (drives unityInstance.Quit())

UseSentry or SentrySdk.Init call

Auto-initialized via Sentry Unity's SentryOptions.asset ScriptableObject (the default Sentry Unity SDK init path — no manual SentrySdk.Init() call in user code). Options Configuration is wired to a SentryOptionsConfiguration : Sentry.Unity.SentryOptionsConfiguration subclass that sets options.EnableBackpressureHandling = false; inside #if UNITY_WEBGL && !UNITY_EDITOR — that one line is the workaround.

Steps to Reproduce

  1. Create a Unity 2021.3.x project with Sentry Unity 4.x installed (default settings — EnableBackpressureHandling = true is the .NET 6.0 default).
  2. Build for WebGL target.
  3. Embed the build in a web page via react-unity-webgl (or any wrapper that calls unityInstance.Quit() on page navigation).
  4. Load the page; let Unity fully initialize.
  5. Trigger unityInstance.Quit() — e.g. press the browser Back button on a page that has a popstate listener wired to call Quit(), or close the tab, or navigate away programmatically.
  6. Observe: the returned Quit() Promise never resolves; the Unity main loop never returns.

Expected Result

unityInstance.Quit() resolves; the page navigates cleanly.

Actual Result

The page wedges indefinitely. The tab appears frozen (no UI updates, the URL may have already updated in the address bar). The user must force-reload to recover. No exception is thrown, no Sentry event is emitted, no console error appears.

Root cause (analysis included for triage convenience, AI generated):

BackpressureMonitor.Dispose() in Internal/BackpressureMonitor.cs deadlocks on single-threaded platforms:

public void Dispose()
{
    try
    {
        _cts.Cancel();
        _workerTask.Wait();   // ← blocks main thread on WebGL
    }
    ...
}

The monitor's worker is started via Task.Run(() => DoWorkAsync(_cts.Token)) and loops on await Task.Delay(10s, ct).

On iOS/Android/Windows this works because Task.Run schedules on a real thread-pool worker. The main thread calls _cts.Cancel(), the worker thread observes cancellation from inside Task.Delay, throws OperationCanceledException, and the task completes — Wait() returns.

On Unity WebGL (single-threaded emscripten) Task.Run schedules on the same main loop as Unity. Task.Delay's continuation is also scheduled on that main loop. When Quit() triggers Dispose(), _workerTask.Wait() synchronously blocks the main thread waiting for a continuation that can only run on the main thread. Classic deadlock.

Confirmed by single-line ablation: setting options.EnableBackpressureHandling = false on WebGL avoids constructing the monitor at all and eliminates the wedge. Bisected version-wise: 3.2.4 (pre-BackpressureMonitor) ✅, 4.0.0 ❌, 4.3.1 ❌, 4.3.1 + workaround ✅.

Metadata

Metadata

Labels

.NETPull requests that update .net codeBugSomething isn't workingNeeds Reproduction

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions