dotnet / dotnet/aspnetcore

[Blazor Hybrid] A single lost render-batch ack permanently corrupts WebViewRenderer's unacknowledged batch queue

Open
#68,674 3 comments 1 reaction 0 assignees View on GitHub
area-blazor investigate Needs: Author Feedback
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

### Describe the bug

**Android triggers this routinely.** It can freeze the out-of-process WebView renderer while the host app process keeps running, so .NET goes on producing render batches for a JS side that cannot receive them. When the renderer resumes, a single missing acknowledgement permanently corrupts `WebViewRenderer`'s unacknowledged-batch queue: `OnAfterRenderAsync` stops firing for good, the UI silently stops updating, and no error is surfaced anywhere.

`WebViewRenderer.NotifyRenderCompleted` dequeues the next unacknowledged batch *before* validating that the acknowledgement matches it, and on mismatch throws without restoring the entry or completing its `TaskCompletionSource`:

https://github.com/dotnet/aspnetcore/blob/v11.0.0-preview.7.26381.103/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs#L75-L83

```csharp
public void NotifyRenderCompleted(long batchId)
{
var nextUnacknowledgedBatch = _unacknowledgedRenderBatches.Dequeue(); // dequeued before validation
if (nextUnacknowledgedBatch.BatchId != batchId)
{
throw new InvalidOperationException($"Received unexpected acknowledgement for render batch {batchId} (next batch should be {nextUnacknowledgedBatch.BatchId})");
}

nextUnacknowledgedBatch.CompletionSource.SetResult();
}
```

If the transport ever drops a `RenderBatch` message, one acknowledgement goes missing and this method is entered with a mismatched id. Two things then go wrong permanently:

1. **The dequeued batch is lost and its `TaskCompletionSource` is never completed**, so the `Task` returned by `UpdateDisplayAsync` for that batch never completes. Anything awaiting it waits forever — notably [`InvokeRenderCompletedCallsAfterUpdateDisplayTask`](https://github.com/dotnet/aspnetcore/blob/v11.0.0-preview.7.26381.103/src/Components/Components/src/RenderTree/Renderer.cs#L936), which means **`OnAfterRenderAsync` never fires for every component in that batch**, and [`RemoveEventHandlerIds(…, updateDisplayTask)`](https://github.com/dotnet/aspnetcore/blob/v11.0.0-preview.7.26381.103/src/Components/Components/src/RenderTree/Renderer.cs#L1099), which means those event-handler registrations are leaked.

2. **The queue stays misaligned.** Every subsequent acknowledgement dequeues one more entry and throws again. There is no resync path, so the renderer never recovers.

This is not a benign diagnostic: the process keeps rendering (`ProcessRenderQueue` does not await `updateDisplayTask`), so .NET believes the UI is up to date while the DOM is frozen at the last successfully applied batch and no further `OnAfterRenderAsync` ever runs. Components that create their JS interop objects in `OnAfterRenderAsync` — a very common pattern — are silently never initialized. In our app this presents as list components stuck on loading skeletons indefinitely and panels that stay blank, with no error surfaced to the user.

Note that the JS side acknowledges every batch it receives, in order, and does so even when applying the batch throws:

https://github.com/dotnet/aspnetcore/blob/v11.0.0-preview.7.26381.103/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts#L20-L27

So a missing acknowledgement always means the message never reached JS — the JS side never skips one.

### Expected Behavior

A lost or out-of-order acknowledgement should not put the renderer into a permanently broken state.

At minimum:

- Validate before mutating the queue (peek, then dequeue), so a spurious or duplicate ack doesn't consume an unrelated batch.
- Complete the affected `TaskCompletionSource` (faulted or cancelled) rather than abandoning it, so awaiting code fails fast instead of hanging forever.
- Either resync — discard entries up to and including the acknowledged id, which is the recoverable interpretation when the transport dropped messages — or fail the page context deterministically so the host can recreate it.

Silently hanging every future `OnAfterRenderAsync` is the worst of the available outcomes, because nothing observable fails.

### Steps To Reproduce

The state corruption is reachable directly:

1. Attach a page and let the renderer send a few batches.
2. Call `NotifyRenderCompleted` with an id ahead of the queue head (simulating a dropped `RenderBatch` message).
3. Observe that the head entry has been consumed, its `CompletionSource` never completes, and every subsequent well-formed ack now also throws.

How we hit it in the wild (.NET MAUI BlazorWebView on Android):

1. Run a BlazorWebView app that keeps rendering while backgrounded (ours has live data arriving over RPC, roughly one render batch every 10 seconds).
2. Send the app to the background. Android demotes the out-of-process WebView renderer (`SandboxedProcessService0`) and, once it is cached, freezes it via the Cached App Freezer. The host app process is *not* frozen and keeps rendering.
3. Leave it backgrounded for a while, then resume.
4. On resume, `NotifyRenderCompleted` is called with an id far ahead of the queue head.

We observed both a short (~100 s, renderer never frozen) and a long (39 min, renderer frozen) variant, so freezing is not required — the WebView merely being invisible is enough to stop acknowledgements arriving while batches keep being produced.

### Exceptions (if any)

Two consecutive acknowledgements after a 39-minute background period, showing the queue advancing by one on each failure and never resyncing:

```
08-20 14:51:26.537 InvalidOperationException: Received unexpected acknowledgement for render batch 375 (next batch should be 169)
at Microsoft.AspNetCore.Components.WebView.Services.WebViewRenderer.NotifyRenderCompleted(Int64 batchId)
at Microsoft.AspNetCore.Components.WebView.IpcReceiver.OnMessageReceivedAsync(PageContext pageContext, String message)
at Microsoft.AspNetCore.Components.WebView.WebViewManager.<>c__DisplayClass20_0.<b__0>d.MoveNext()
--- End of stack trace from previous location ---
at Microsoft.Maui.Dispatching.DispatcherExtensions.<>c__DisplayClass3_0.<b__0>d.MoveNext()

08-20 14:51:26.544 InvalidOperationException: Received unexpected acknowledgement for render batch 376 (next batch should be 170)
at Microsoft.AspNetCore.Components.WebView.Services.WebViewRenderer.NotifyRenderCompleted(Int64 batchId)
...
```

A shorter background period on the same device produced the same failure with a smaller gap (batch 4573, next should be 4563).

### .NET Version

11.0.100-preview.7.26381.103

### Anything else?

.NET MAUI 11.0.0-preview.7.26406.9, `net11.0-android`, targetSdk 37, Android 16 (Samsung SM-S948U1), out-of-process WebView renderer.

The gap between the acknowledged id and the queue head scales linearly with how long the WebView was invisible (roughly one batch per 10 s in our app: 10 batches over 100 s, 207 over 39 minutes), which is what makes the impact grow with background time.

Related: the same code path has no flow control — `UpdateDisplayAsync` enqueues and sends unconditionally with no cap on the unacknowledged queue, unlike Blazor Server's `MaxBufferedUnacknowledgedRenderBatches`. I'm filing that separately; it is what allows hundreds of batches to accumulate against a WebView that is not consuming them.

I have not root-caused *why* `WebView.PostWebMessage` messages fail to reach a backgrounded or frozen renderer on Android — that is below the aspnetcore/maui layer. But given that the transport is best-effort with no delivery guarantee or retry, the renderer should be resilient to a dropped message rather than permanently corrupted by one.

Contributor guide

Open the contributing guide

Research direction

Start in src/Components/WebView/WebView/src/Services/WebViewRenderer.cs at NotifyRenderCompleted, then trace UpdateDisplayAsync and the Renderer.cs callers named in the issue. Reproduce the direct mismatched-ack scenario and verify that the queue is not corrupted, the affected task completes, and later acknowledgements either recover or fail deterministically.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.