Skip to content

Stop the Blazor admin UI blocking a graceful shutdown - #5693

Open
timothycoleman wants to merge 2 commits into
masterfrom
timothycoleman/blazor-shutdown-circuit
Open

Stop the Blazor admin UI blocking a graceful shutdown#5693
timothycoleman wants to merge 2 commits into
masterfrom
timothycoleman/blazor-shutdown-circuit

Conversation

@timothycoleman

Copy link
Copy Markdown
Contributor

On shutdown SignalR aborts the Blazor circuits it is tracking, and the client immediately reconnects over the HTTP/2 connection the browser already holds. Kestrel is closing that connection, but a stream whose headers were already in flight when GOAWAY went out is still processed, so the reconnect wins the race and a fresh transport request begins after SignalR has finished closing what it was tracking. Nothing then ends that request, so Kestrel's drain waits for it until HostOptions.ShutdownTimeout expires, followed by a forced shutdown.

Refuse /_blazor requests with a 503 once ApplicationStopping has fired, and abort a circuit request that started just before the signal. Cancelling RequestAborted is not enough there - the transport ignores it and runs on until Kestrel resets the stream at the timeout.

The middleware is registered ahead of Startup.Configure because routing and the endpoints are set up in there, and a circuit request would otherwise be handled by its endpoint before reaching the middleware.

Workaround for dotnet/aspnetcore#58947, which is still open; the root cause is confirmed in
dotnet/aspnetcore#58947 (comment)

Copilot AI review requested due to automatic review settings July 31, 2026 15:09
@timothycoleman
timothycoleman requested a review from a team as a code owner July 31, 2026 15:09
@timothycoleman timothycoleman added the ignore-for-release Exclude this PR from release notes. e.g. bugfix for non-released bug, small test/ci changes label Jul 31, 2026
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Prevent Blazor admin UI circuits from blocking graceful shutdown

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add middleware to refuse new Blazor circuit requests during ApplicationStopping.
• Abort in-flight circuit transports opened just before shutdown to unblock Kestrel drain.
• Register middleware before endpoint routing and add focused unit coverage.
Diagram

graph TD
A["Browser (Blazor)"] --> B["Kestrel / ASP.NET host"] --> C["BlazorShutdownMiddleware"] --> D{"ApplicationStopping?"}
D -->|"No"| E["Register abort-on-stop"] --> F["/_blazor endpoint (SignalR)"] --> G["Circuit transport request"]
D -->|"Yes"| H["Return 503"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Drain admin UI earlier via orchestration (LB/health/stop serving UI)
  • ➕ Avoids in-process workarounds for upstream SignalR behavior
  • ➕ Can be applied to other long-lived endpoints beyond Blazor
  • ➖ Requires deployment/orchestration support and correct sequencing
  • ➖ Doesn’t protect against same-process restarts without external coordination
2. Change transport characteristics (e.g., disable HTTP/2 for /_blazor)
  • ➕ May avoid the specific GOAWAY/stream-race that triggers the reconnect
  • ➕ Can reduce likelihood of “headers already in flight” reconnects
  • ➖ Harder to target per-path cleanly; may regress performance/features
  • ➖ Still relies on framework internals and may not fully eliminate the race
3. Endpoint-level gate instead of global middleware (MapWhen/endpoint filter)
  • ➕ More explicit scoping to Blazor endpoints
  • ➕ Can integrate with routing metadata
  • ➖ Must still run before the /_blazor endpoint executes; ordering can be fragile
  • ➖ More moving parts than a single path-scoped middleware

Recommendation: The PR’s approach is a pragmatic, minimal-scope workaround: a path-scoped middleware that (1) returns 503 for new /_blazor requests once ApplicationStopping is set, and (2) aborts already-started circuit transports when stopping triggers. Given the upstream ASP.NET Core issue remains open, this is the most reliable in-process mitigation with clear behavior and unit coverage; keep it until the framework stops accepting late circuit connections during shutdown.

Files changed (3) +137 / -0

Bug fix (2) +50 / -0
BlazorShutdownMiddleware.csIntroduce middleware to refuse/abort Blazor circuit requests during shutdown +45/-0

Introduce middleware to refuse/abort Blazor circuit requests during shutdown

• Adds BlazorShutdownMiddleware that intercepts /_blazor traffic. When stopping has begun it returns 503; otherwise it registers an ApplicationStopping callback that calls HttpContext.Abort() to ensure long-lived transports don’t block Kestrel’s graceful shutdown drain.

src/KurrentDB/BlazorShutdownMiddleware.cs

Program.csRegister BlazorShutdownMiddleware before Startup routing/endpoints +5/-0

Register BlazorShutdownMiddleware before Startup routing/endpoints

• Adds early middleware registration (when Admin UI is enabled) so /_blazor requests are gated before endpoint routing handles them, ensuring shutdown protection is effective.

src/KurrentDB/Program.cs

Tests (1) +87 / -0
BlazorShutdownMiddlewareTests.csAdd unit tests covering Blazor shutdown gating and abort behavior +87/-0

Add unit tests covering Blazor shutdown gating and abort behavior

• Introduces tests validating that non-Blazor requests pass through, that /_blazor negotiate/transport requests are rejected with 503 during ApplicationStopping, and that an in-flight circuit is aborted when shutdown begins.

src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an ASP.NET Core middleware to prevent the embedded Blazor admin UI from re-establishing SignalR circuits during shutdown, which can otherwise keep Kestrel draining until HostOptions.ShutdownTimeout and force an ungraceful shutdown.

Changes:

  • Registers a new middleware early in the pipeline (before Startup.Configure) to intercept /_blazor requests.
  • Implements BlazorShutdownMiddleware to return 503 once ApplicationStopping has fired and to Abort() in-flight circuit requests when shutdown begins.
  • Adds unit tests covering pass-through behavior, refusal during stopping, and aborting an in-flight circuit.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/KurrentDB/Program.cs Registers BlazorShutdownMiddleware early to ensure /_blazor requests are intercepted before endpoint handling.
src/KurrentDB/BlazorShutdownMiddleware.cs Introduces middleware that refuses new circuits during shutdown and aborts existing circuit requests when stopping starts.
src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs Adds tests verifying the middleware behavior for non-circuit paths, circuit refusal during stopping, and aborting pre-shutdown circuits.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs
@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. FakeLifetime CTS not disposed ✓ Resolved 📘 Rule violation ☼ Reliability
Description
FakeLifetime creates a CancellationTokenSource but never disposes it, violating the required
ownership lifecycle. This can leak resources and violates the CTS lifecycle policy even in test
utilities.
Code

src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs[R14-21]

+	class FakeLifetime : IHostApplicationLifetime {
+		readonly CancellationTokenSource _stopping = new();
+
+		public CancellationToken ApplicationStarted => CancellationToken.None;
+		public CancellationToken ApplicationStopping => _stopping.Token;
+		public CancellationToken ApplicationStopped => CancellationToken.None;
+		public void StopApplication() => _stopping.Cancel();
+	}
Evidence
PR Compliance ID 3 requires that the creator of a CancellationTokenSource must cancel and dispose
it. In FakeLifetime, _stopping is created but there is no Dispose() implementation and the
tests do not dispose FakeLifetime, so _stopping is never disposed.

CLAUDE.md: CancellationTokenSource ownership: creator must cancel and dispose
src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs[14-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`FakeLifetime` creates a `CancellationTokenSource` (`_stopping`) but never disposes it.

## Issue Context
Compliance requires the component that creates a `CancellationTokenSource` to own its full lifecycle (cancel and dispose). This `CancellationTokenSource` is created inside the test helper type and should be disposed deterministically.

## Fix Focus Areas
- src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs[14-21]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/KurrentDB.Components.Tests/BlazorShutdownMiddlewareTests.cs Outdated
On shutdown SignalR aborts the Blazor circuits it is tracking, and the client
immediately reconnects over the HTTP/2 connection the browser already holds.
Kestrel is closing that connection, but a stream whose headers were already in
flight when GOAWAY went out is still processed, so the reconnect wins the race and
a fresh transport request begins after SignalR has finished closing what it was
tracking. Nothing then ends that request, so Kestrel's drain waits for it until
HostOptions.ShutdownTimeout expires, followed by a forced shutdown.

Refuse /_blazor requests with a 503 once ApplicationStopping has fired, and abort a
circuit request that started just before the signal. Cancelling RequestAborted is
not enough there - the transport ignores it and runs on until Kestrel resets the
stream at the timeout.

The middleware is registered ahead of Startup.Configure because routing and the
endpoints are set up in there, and a circuit request would otherwise be handled by
its endpoint before reaching the middleware.

Workaround for dotnet/aspnetcore#58947, which is still
open; the root cause is confirmed in
dotnet/aspnetcore#58947 (comment)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@timothycoleman
timothycoleman force-pushed the timothycoleman/blazor-shutdown-circuit branch from 3e6c9d7 to 8ca93dd Compare July 31, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ignore-for-release Exclude this PR from release notes. e.g. bugfix for non-released bug, small test/ci changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants