dotnet / dotnet/runtime

[browser] Cancelling an HttpClient request during the body read orphans the JS promise Task, surfacing a raw JSException via TaskScheduler.UnobservedTaskException

Open
#133,667 3 comments 0 reactions 1 assignee Claimed by @pavelsavara View on GitHub
arch-wasm area-System.Net.Http bug os-browser
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

In Blazor WebAssembly, cancelling an `HttpClient` request while its response body is being read leaves a JS-promise-backed `Task` inside `BrowserHttpHandler` that is never awaited. When the aborted read later rejects, the fault surfaces through `TaskScheduler.UnobservedTaskException` as an `AggregateException` whose only inner exception is a raw `System.Runtime.InteropServices.JavaScript.JSException` with no managed frames.

The caller receives the expected `OperationCanceledException`. The orphaned task is an extra, invisible fault that application code cannot catch, and that error-reporting SDKs (Sentry, Application Insights, ...) record as an unhandled error. Two messages occur, depending on which body-read path was in flight:

```
System.AggregateException ---> System.Runtime.InteropServices.JavaScript.JSException: Error: OperationCanceledException
System.AggregateException ---> System.Runtime.InteropServices.JavaScript.JSException: AbortError: BodyStreamBuffer was aborted

### Reproduction Steps

Minimal, deterministic repro (standalone Blazor WebAssembly client from the empty template plus a 25-line minimal API that streams slowly; the server is required because the fault only occurs while a response body is still arriving, and a browser fetch cannot be driven from a console app):

https://github.com/D1lsh0D/blazor-wasm-orphaned-fetch-cancellation-repro

```
git clone https://github.com/D1lsh0D/blazor-wasm-orphaned-fetch-cancellation-repro
cd blazor-wasm-orphaned-fetch-cancellation-repro
dotnet run --project src/Repro.Server # GET http://localhost:5299/slow streams 64 KB every 300 ms
dotnet run --project src/Repro.Client # http://localhost:5298
```

Open http://localhost:5298/ with the browser devtools console visible. `src/Repro.Client/Pages/Home.razor` subscribes to `TaskScheduler.UnobservedTaskException`, runs two scenarios against the slow endpoint using `HttpCompletionOption.ResponseHeadersRead`, and forces garbage collection after each (comments in the file state the expected and actual behavior):

- **A.** Read the first streamed chunk into a 1 MB buffer (large enough to consume the whole chunk, so the next read must call the JS reader again), cancel the token, issue a second `ReadAsync`.
- **B.** Cancel the token right after the headers arrive, then issue the first `ReadAsync`.

Reload the page to run again. The result was identical on every run.

### Expected behavior

Each cancelled `ReadAsync` throws `OperationCanceledException` to the caller and nothing else happens. `TaskScheduler.UnobservedTaskException` is never raised, because the runtime observes (or does not create) the JS promise it started for the read.

### Actual behavior

Each cancelled `ReadAsync` does throw `OperationCanceledException`, but the runtime also leaves a faulted, never-awaited `Task`. After GC, `TaskScheduler.UnobservedTaskException` fires once per scenario:

```
[repro] runtime .NET 10.0.12
[repro] --- A: cancel between streamed body reads
[repro] first read: 65536 bytes
[repro] second read threw OperationCanceledException (expected)
[repro] UNOBSERVED: System.Runtime.InteropServices.JavaScript.JSException: Error: OperationCanceledException
[repro] --- B: cancel after headers, before the first body read
[repro] headers received: 200
[repro] read threw OperationCanceledException (expected)
[repro] UNOBSERVED: System.Runtime.InteropServices.JavaScript.JSException: AbortError: BodyStreamBuffer was aborted
[repro] done
```

In a production Blazor WASM application (Sentry's `UnobservedTaskException` integration, .NET 10.0.9 to 10.0.11, Chromium-based browsers) the two signatures fired about 140 times over six months across about 30 users, always coinciding with legitimate cancellation (page teardown, superseded search requests).

### Regression?

Behaviourally yes, from .NET 9 to .NET 10. The same production app ran seven months on .NET 9 with the same telemetry and never produced either signature; both appeared within a week of the .NET 10 upgrade.

The code path exists in `release/9.0` as well, but response streaming (`System.Net.Http.WasmEnableStreamingResponse`) was off by default there, so only the buffered `arrayBuffer()` path with one narrow race window per request was exposed. .NET 10 enables streaming by default, which turns every chunk boundary of every response into a window.

### Known Workarounds

None that prevent the fault; application code has no reference to the orphaned task and no frame in it.

- Consumers of `TaskScheduler.UnobservedTaskException` (or error-reporting SDKs) can filter events whose chain is `AggregateException` over `System.Runtime.InteropServices.JavaScript.JSException` with message `Error: OperationCanceledException` or an `AbortError` prefix. That is what we shipped.
- By code inspection, `SetBrowserResponseStreamingEnabled(false)` would shrink the window back to the .NET 9 size but not remove it (`BrowserHttpContent.GetResponseData` has the same shape). Not verified.

### Configuration

- .NET SDK 10.0.204, runtime 10.0.12 (`browser-wasm`, Mono), Blazor WebAssembly standalone empty template
- Host: Windows 11 Enterprise 10.0.26100, x64; browser: Chromium (Playwright build chromium-1181)
- Production observations: .NET 10.0.9, 10.0.10, 10.0.11 in Chromium-based browsers on Windows and macOS
- `System.Net.Http.WasmEnableStreamingResponse` at its default (`true`)

### Other information

Analysis against `release/10.0` (identical code in `release/9.0` and `main`):

1. `src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/BrowserHttpInterop.cs`, `CancellationHelper(Task promise, CancellationToken cancellationToken, JSObject jsController)`: the first statement is `Http.CancellationHelper.ThrowIfCancellationRequested(cancellationToken);`, before `await promise`. If the token is already cancelled on entry, the helper throws and the `promise` it was handed is never awaited or observed.

2. Its callers create the JS promise first:
- `BrowserHttpReadStream.ReadAsync`: `var promise = BrowserHttpInterop.GetStreamedResponseBytesUnsafe(...)` then `await CancellationHelper(promise, cancellationToken, ...)`.
- `BrowserHttpContent.GetResponseData`: `promise = BrowserHttpInterop.GetResponseLength(...)` then `await CancellationHelper(promise, cancellationToken, ...)`.

`ThrowIfDisposed()` does not guard this: the `BrowserHttpController` constructor registers only `BrowserHttpInterop.Abort(httpController)` on the token, so `_isDisposed` stays `false` after cancellation.

3. The orphaned promise then rejects (`src/mono/browser/runtime/http.ts`):
- Scenario A: `http_wasm_abort` has already called `streamReader.cancel()` and set `isAborted = true`, so in `http_wasm_get_streamed_response_bytes` the pending `read()` resolves with `done` and the function throws `new Error("OperationCanceledException")`; `marshal_exception_to_cs` uses `toString()`, giving `JSException("Error: OperationCanceledException")`.
- Scenario B: no reader existed when `http_wasm_abort` ran, so it called `abortController.abort("AbortError")`; the first `read()` on the aborted body rejects with Chromium's `DOMException`, giving `JSException("AbortError: BodyStreamBuffer was aborted")`.

`PromiseHolder.reject` calls `complete_task`, which sets the exception on a `TaskCompletionSource` that has no awaiter; the finalizer then raises `UnobservedTaskException`. The `catch (JSException jse) when jse.Message.StartsWith("AbortError")` conversion inside `CancellationHelper` never runs for these promises because the helper exited before the `await`.

Secondary concern in `ReadAsync`: the `finally` unpins the buffer immediately after the early throw, while the orphaned JS continuation may still write into it (the comment there says the unpin "must be after await" for exactly this reason).

Suggested fix:

- In `BrowserHttpReadStream.ReadAsync` and `BrowserHttpContent.GetResponseData`, call `cancellationToken.ThrowIfCancellationRequested()` before creating the JS promise.
- In `CancellationHelper`, when returning or throwing before the `await`, observe the promise, for example `promise.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);`, or set up the cancellation registration first and always await.

Related: #129758 shows the same `JSException: Error: OperationCanceledException` text from the cancelled-promise path, but there it is an awaited, converted exception in a CoreCLR-wasm streaming-request test; different bug.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.