Skip to content

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

@ruslan-kodable

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

Assignees

No one assigned

    Labels

    .NETPull requests that update .net codeBugSomething isn't workingNeeds Reproduction
    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