Repository navigation
[Blazor] Error UI is shown on a transient connection failure during reconnection when skipNegotiation is used, and stays after a successful reconnect #69596
Description
Is there an existing issue for this?
- I have searched the existing issues
Describe the bug
Closest existing issues I found: #58932 has the same console signature (startConnection -> unhandledError) but a different root cause (IIS + WebSocket compression); #25951 / #26174 are the 3.1-era "error UI after inactivity" reports with a different cause.
With skipNegotiation: true (WebSockets-only, no negotiate request), any failure of connection.start() during an automatic reconnection attempt shows #blazor-error-ui ("An unhandled error has occurred. Reload"). The reconnection handler then keeps retrying, the next attempt succeeds, the circuit is resumed (no reload) and the app works again, but the error UI stays until the user dismisses it. The user sees a fatal-looking error for a transient network blip that the framework recovered from.
The cause seems to be the catch block in CircuitManager.startConnection() (src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts, lines 231-244 on main):
try {
await connection.start();
this.handleConnectionUp();
} catch (ex: any) {
this.unhandledError(ex as Error);
if (ex.errorType === 'FailedToNegotiateWithServerError') {
// Connection with the server has been interrupted, and we're in the process of reconnecting.
// Throw this exception so it can be handled at the reconnection layer, and don't show the
// error notification.
throw ex;
} else {
showErrorNotification();
}
...
}
return connection;The reconnect case is only recognised via FailedToNegotiateWithServerError. With skipNegotiation: true there is no negotiate request, so a failed reconnection attempt surfaces as a transport error (WebSocket failed to connect, or WebSocket closed with status code: 1006 if the socket dies before the handshake completes). That goes to showErrorNotification(). The error is also not rethrown, so reconnect() goes on to call invoke('ConnectCircuit') on a connection that is not connected, which logs a second error: Cannot send data if the connection is not in the 'Connected' State.
Expected Behavior
A failed connection start during reconnection is handled by the reconnection layer (reconnect modal / retry) and does not show the fatal error UI. This is already the behaviour when negotiation is used.
Possible fix: in startConnection(), when it is called from reconnect() (or the circuit was already started), rethrow instead of calling showErrorNotification(), regardless of the error type.
Steps To Reproduce
The repro is the empty Blazor Server template with one file changed, so it is inlined here rather than in a separate repo (happy to publish one if needed).
dotnet new blazor --interactivity Server --empty -o ReconnectRepro- In
Components/App.razormake the app interactive and start Blazor withskipNegotiation:
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]" autostart="false"></script>
<script>
Blazor.start({
circuit: {
configureSignalR: function (builder) {
builder.withUrl("_blazor", { skipNegotiation: true, transport: 1 /* WebSockets */ });
}
}
});
</script>- Run the app on
http://127.0.0.1:5299and put a TCP proxy in front of it on127.0.0.1:5298(any proxy works; I used a ~20 line Python asyncio one) so the network can be cut while the server and circuit stay alive. - Open
http://127.0.0.1:5298/and wait forWebSocket connected. - Stop the proxy. The reconnect modal appears and the error UI appears at the same time.
- Start the proxy again within the circuit retention period. The modal goes away, the circuit is resumed (no page reload;
performance.getEntriesByType('navigation')[0].typeis stillnavigate,performance.now()keeps counting), but#blazor-error-uistaysdisplay: block.
Control: the same steps without skipNegotiation: true ({ transport: 1 } only) show only the reconnect modal. The error UI stays hidden because the failure is a FailedToNegotiateWithServerError, which is rethrown.
Stopping the server instead of the proxy also shows the error UI during reconnection (then the circuit is gone and the default ReconnectModal.razor.js reloads the page, so it is less visible).
Exceptions (if any)
Browser console at the moment the connection drops (blazor.web.js from 10.0.12):
Error: Connection disconnected with error 'Error: WebSocket closed with status code: 1006 (no reason given).'.
Information: (WebSockets transport) There was an error with the transport.
Error: Failed to start the connection: Error: WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.
Error: Error: WebSocket failed to connect. The connection could not be found on the server, ... <- unhandledError() in startConnection, error UI shown here
Error: Error: Cannot send data if the connection is not in the 'Connected' State. <- reconnect() invoking ConnectCircuit on the failed connection
No server-side exception or log entry. From the server's point of view, it is only a disconnect followed by a reconnect.
.NET Version
10.0.401 (SDK), ASP.NET Core runtime 10.0.12
Anything else?
ASP.NET Core version: 10.0.12 (the code quoted above is unchanged on main)
The IDE: none (dotnet CLI); browser: desktop Chrome on Windows 11
How we hit it in production: Blazor Server app behind nginx and Cloudflare Tunnel, skipNegotiation: true, desktop Chrome. The tab sat in the background for about an hour. Chrome kept closing the WebSocket (WebSocket connection to 'wss://…/_blazor' failed: Page entered Back-Forward Cache.) and Blazor reconnected about once a minute. Most reconnects were clean. In one of them the new socket was closed before the SignalR handshake completed, connection.start() rejected with WebSocket closed with status code: 1006, and the error UI appeared. The next attempt two seconds later succeeded, and the app kept working after the error UI was dismissed.
Activity