fix(aspnetcore): guard hosted-service stop against concurrent double Host.StopAsync#6397
Conversation
…Host.StopAsync Minimal-hosting SUTs park app.Run() in WaitForShutdownAsync, which calls Host.StopAsync when ApplicationStopping fires - concurrently with the Host.StopAsync already in flight from WebApplicationFactory.DisposeAsync (the trigger of that signal). Host.StopAsync has no concurrency guard, so every IHostedService.StopAsync ran twice in parallel, breaking services with non-thread-safe shutdown (e.g. Rebus bus dispose throws ObjectDisposedException from an After(Test) hook). FlowSuppressingHostedService already wraps every hosted service, so its stop-phase methods now run the inner stop exactly once; duplicate and concurrent callers observe the same task. Reproduced pre-fix by the new end-to-end probe (4-8 failures per 50 tests), clean across 6 runs post-fix. Fixes #6339
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 51 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Review
What it does: Fixes issue #6339 by guarding FlowSuppressingHostedService's StopAsync/StoppingAsync/StoppedAsync so the wrapped hosted service's stop-phase methods run exactly once, even when Host.StopAsync is invoked concurrently twice (which happens when WebApplicationFactory.DisposeAsync races a minimal-hosting SUT's own WaitForShutdownAsync path). Previously unguarded stop calls could run the inner service's shutdown logic twice in parallel, breaking non-thread-safe shutdown code (e.g. Rebus) with ObjectDisposedException.
Correctness
- The
lock (_stopGate) { return _field ??= InvokeOnce(...); }pattern is a standard "cache the started task" idiom (akin toAsyncLazy) — correct and race-free: the lock is only held for the synchronous portion of kicking off the call, and all callers (concurrent or sequential) subsequently await the same cachedTask. InvokeOncecorrectly captures a synchronous throw as a faultedTaskso it gets cached too — without this, a sync-throwing innerStopAsyncwould re-execute on every duplicate call, defeating the once-guard. Verified bySynchronously_Throwing_Stop_Is_Cached_Not_Reinvoked.- Sharing one
_stopGatelock object across three independent cached-task fields (_stopTask,_stoppingTask,_stoppedTask) is fine — no cross-field interaction or reentrancy risk sinceInvokeOncenever calls back into the wrapper. - The
is not IHostedLifecycleServicefast path inStoppingAsync/StoppedAsynccorrectly returns before touching the lock, matching prior behavior for non-lifecycle inner services. - One implicit design tradeoff worth flagging in case it's not intentional: the cached task is never reset, so if a host were ever restarted after being stopped (start → stop → start → stop), the second
StopAsyncwould return the already-completed first task without re-invoking the inner service. This matches the PR's stated scope (WebApplicationFactorydisposal, not host restart), but might be worth a one-line remark in the class doc if restart-after-stop is a scenario TUnit's hosting wrapper needs to support elsewhere.
Test coverage
HostedServiceStopOnceTestsis a solid, fast unit-level suite: concurrent (16-way) and sequential double-stop, task-identity check, sync-throw caching, and lifecycle-hook guarding. Good use of aTaskCompletionSourcegate to force real overlap rather than relying on timing.HostStopExactlyOnceProbeTestsis a good end-to-end regression test — it reproduces the real race viaWebApplicationFactory+ minimal hosting rather than only unit-testing the wrapper in isolation, and the probe throws with both stack traces on a double-entry, making a future regression easy to diagnose from CI output alone.[Repeat(50)]plus a random jitter is a reasonable way to reliably surface a race that was previously only observed in 4-8/50 runs.- Both new test files follow existing conventions in
TUnit.AspNetCore.Tests(assertion style,WebApplicationTest<TestWebAppFactory, Program>base usage,InternalsVisibleToaccess to theinternalwrapper class).
Minor / non-blocking
StopProbeHostedService.StopAsyncbuilds the exception message (withEnvironment.StackTrace) even in the non-throwing path is avoided (good — it's only built onentry > 1), so no perf concern there.- No architectural concerns — this is a narrowly-scoped, well-isolated fix at the correct interception point given the upstream
Mvc.Testinggap described in the PR body, and doesn't change behavior for the (presumably far more common) non-racing shutdown path.
Overall: the fix is correct, minimal, and well-tested. No changes requested.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a37fe9c3c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| { | ||
| lock (_stopGate) | ||
| { | ||
| return _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); |
There was a problem hiding this comment.
Preserve each StopAsync caller's cancellation token
When a second Host.StopAsync arrives while the first stop is still running, this returns the cached task that was created with the first caller's token, so the duplicate caller's token is never observed. In contexts that pass a shorter or already-canceling shutdown token, that caller can now wait for the original stop task and run past its own timeout instead of canceling; share the single inner invocation, but have duplicate waits respect their own cancellationToken.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1ac3c735c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| lock (_stopGate) | ||
| { | ||
| task = _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); | ||
| } |
There was a problem hiding this comment.
Avoid invoking StopAsync while holding the gate
When the first hosted service performs slow synchronous cleanup before returning its Task, a concurrent Host.StopAsync with a shorter or already-canceled token will block on _stopGate until this call returns, so it never reaches WaitWithCancellation and can run past its own shutdown timeout. This is fresh evidence beyond the earlier cached-task review comment: the inner stop is now still invoked while the monitor is held, before duplicate callers can obtain a cancellable wait on the shared task. Publish a placeholder under the lock and invoke the inner stop outside the monitor so duplicate waits can respect their own token immediately.
Useful? React with 👍 / 👎.
Review update (re-review after new commit)My previous review covered the first commit ( What it changes
Why it's correct
CLAUDE.md complianceNo violations — no blocking on async ( No issues found in this delta. Fix remains correct, minimal, and well-tested. |
Review update (re-review after latest commit)Prior reviews covered up through What it changes
Why it's correct
VerificationRan the new tests on this branch directly ( CLAUDE.md complianceNo violations — no blocking on async, no VSTest usage, no source-gen/snapshot changes, no reflection requiring AOT annotations. No issues found. This delta correctly resolves the remaining Codex P2 concern without reintroducing any of the earlier ones. Fix is correct, minimal, and well-tested. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dfd6df6b7f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _ = ExecuteAndCompleteAsync(completionSource, operation, cancellationToken); | ||
| } | ||
|
|
||
| return WaitWithCancellation(task, cancellationToken); |
There was a problem hiding this comment.
Avoid canceling the owning stop wait
When this is the first caller, task is the placeholder for the actual inner stop, but returning it through WaitWithCancellation means Host.StopAsync's shutdown-timeout token can cancel the wrapper while ExecuteAndCompleteAsync continues running the inner StopAsync in the background. For services that ignore the token or take longer than the timeout, the factory/host can proceed to dispose the service provider concurrently with stop cleanup, whereas hosted-service shutdown normally still awaits the returned tasks after cancellation is requested (docs). Apply the per-caller WaitAsync only to duplicate callers, and let the owning invocation return the cached stop task.
Useful? React with 👍 / 👎.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Review:
|
Root cause (issue #6339, post-1.58.0 report)
The reporter's remaining
ObjectDisposedExceptionin theAfter(Test)hook is not the timeout-CTS bug fixed in #6340/#6349 — their new inner stack shows it thrown inside Rebus' bus-dispose chain duringWebApplicationFactory.DisposeAsync.Mechanism (confirmed with an instrumented probe, no Rebus needed):
app.Run()insideWaitForShutdownAsync, which callsHost.StopAsyncwhenApplicationStoppingfires.After(Test)→WebApplicationFactory.DisposeAsync→DeferredHost.StopAsync→Host.StopAsync— which firesApplicationStopping, waking the parked shutdown path while its own stop is still in flight.Host.StopAsynchas no concurrency guard, so everyIHostedService.StopAsyncruns twice, concurrently. Services with non-thread-safe shutdown break — Rebus'RebusBus.Dispose/InMemErrorTracker/TplAsyncTaskguards are plain bools with check-then-act windows, producing exactly the reported ODE.Probe stacks captured pre-fix: one stop from
HostingAbstractionsHostExtensions.WaitForShutdownAsync, the other fromWebApplicationFactory.DisposeAsyncvia the After hook — overlapping on the same host. 4–8 failures per 50 tests.Fix
FlowSuppressingHostedServicealready wraps every hosted service in the SUT, so its stop-phase methods (StopAsync,StoppingAsync,StoppedAsync) now run the inner stop exactly once — duplicate and concurrent callers observe the same underlying task. Synchronous throws are cached as faulted tasks so a duplicate caller can't re-invoke.This is fundamentally an aspnetcore
Mvc.Testingdesign gap (nothing synchronizesDeferredHostdisposal with the app's ownRunAsyncshutdown), but the wrapper is the right interception point to shield TUnit users today.Tests
HostedServiceStopOnceTests— deterministic unit tests: 16-way concurrent stop → inner sees 1; sequential re-stop no-ops; same-task identity; sync-throw cached; lifecycle hooks once-guarded.HostStopExactlyOnceProbeTests— end-to-end: 50 parallel factory lifecycles, probe hosted service throws with both stacks on any secondStopAsyncentry. Failed 4/51 and 2/51 pre-fix; clean across 6 consecutive runs post-fix.TUnit.AspNetCore.Testssuite: 92/92 (net10.0); all TFMs build.Fixes #6339