fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651)#5652
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 9 |
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. Give us feedback
There was a problem hiding this comment.
Code Review
This is a clean, well-reasoned fix for a real resource-leak bug. The implementation is minimal, correct, and follows established patterns in the codebase. A few observations below.
What's Good
Disposal preference ordering is correct. DisposeAsync preferring IAsyncDisposable over IDisposable matches how Microsoft.Extensions.DependencyInjection's ServiceProvider behaves and what consumers expect.
The no-op on Dispose() for async-only inners is the right call. Blocking on DisposeAsync().AsTask().GetAwaiter().GetResult() inside Dispose() would violate the project's explicit "never block on async" rule and risks deadlocks. The tradeoff is clearly documented in the XML docs and PR description — exactly the right approach.
Consistent with TracedWebApplicationFactory. The PR description calls this out, and it checks out — TracedWebApplicationFactory uses the same dual-interface forwarding pattern (lines 75–78). This is good precedent to follow.
Test coverage is thorough. All seven disposal scenarios are covered, and the probe classes are clear and minimal. The tests construct the wrapper directly without a host or DI container, correctly isolating the forwarding contract.
InternalsVisibleTo is already in place. TUnit.AspNetCore.Core.csproj already includes TUnit.AspNetCore.Tests, so new FlowSuppressingHostedService(inner) in the tests compiles as expected.
Minor Observations
1. Wrapper_Does_Not_Throw_When_Inner_Is_Not_Disposable — assertion is indirect
await Assert.That(inner.StartedCount).IsEqualTo(0);StartedCount is 0 because StartAsync was never called — not as a consequence of disposal being safe. The test name says "Does_Not_Throw" but the assertion doesn't directly prove that. The test body is already the implicit assertion (no exception means it passed), so the Assert.That line either isn't needed or could check something more intentional. Low severity — purely cosmetic — but worth noting for future readers.
2. No double-disposal guard
Neither Dispose() nor DisposeAsync() is idempotent. If a caller invokes both (e.g., Dispose() followed by DisposeAsync()), the inner service gets disposed twice. This is the same pattern as TracedWebApplicationFactory, which also has no guard, so it's consistent with the existing codebase convention. And in practice the DI container won't call both.
That said, since FlowSuppressingHostedService now explicitly implements both IDisposable and IAsyncDisposable, it's worth noting this is a mild deviation from Microsoft's guidance which recommends idempotent disposal when both interfaces are implemented. A simple _disposed flag would close this:
private int _disposed;
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (inner is IAsyncDisposable asyncDisposable)
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
else if (inner is IDisposable disposable)
disposable.Dispose();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (inner is IDisposable disposable)
disposable.Dispose();
}Whether this is worth adding depends on how defensive you want to be. Given that TracedWebApplicationFactory omits it, omitting it here is defensible too — just flagging the inconsistency with Microsoft's pattern.
Summary
This is a solid fix. The core bug (disposal not forwarded through the wrapper) is correctly resolved, the async/sync fallback logic is right, the no-op decision on sync-path for async-only inners is well-justified and documented, and the tests cover all the relevant combinations. The two observations above are minor and neither blocks merging.
…urst#5651) FlowSuppressingHostedService implements IHostedLifecycleService but did not implement IDisposable or IAsyncDisposable. When the DI container disposed the host, the container only saw the non-disposable wrapper, so the inner service's Dispose / DisposeAsync was never called, leaking unmanaged resources silently. Add both IDisposable and IAsyncDisposable to the wrapper, forwarding to the inner service. DisposeAsync prefers the async path on inner and falls back to sync Dispose when inner is IDisposable only. Dispose forwards only when inner implements IDisposable; async-only inner services are not disposed on the synchronous path, matching the "never block on async" rule in CLAUDE.md. Callers with async-only resources should use DisposeAsync (or await using on the owning factory). Regression coverage: 7 pure-unit-tests in HostedServiceDisposalForwardingTests covering single-interface, dual-interface, async-only-on-sync-dispose no-op, and non-disposable inner cases. Verified stable over 10 consecutive runs (70/70 assertion successes). Fixes thomhurst#5651
9617e1a to
dc08af3
Compare
|
Thanks for the careful review. Obs 1 (indirect assertion): addressed in the force-push to Obs 2 (double-disposal guard): keeping the current shape to stay aligned with |
|
Thanks @JohnVerheij ! Nice catch |
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.37.10 to 1.39.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 Commits viewable in [compare view](thomhurst/TUnit@v1.37.10...v1.39.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Description
FlowSuppressingHostedService implements IHostedLifecycleService but did not
implement IDisposable or IAsyncDisposable. When the DI container disposed the
host, the container only saw the non-disposable wrapper, so the inner service's
Dispose / DisposeAsync was never called, leaking unmanaged resources silently.
Add both IDisposable and IAsyncDisposable to the wrapper, forwarding to the
inner service. DisposeAsync prefers the async path on inner and falls back to
sync Dispose when inner is IDisposable only. Dispose forwards only when inner
implements IDisposable; async-only inner services are not disposed on the
synchronous path, matching the "never block on async" rule in CLAUDE.md.
Callers with async-only resources should use DisposeAsync (or await using on
the owning factory).
Follows the dual-interface forwarding precedent already in TracedWebApplicationFactory. That class can call
_inner.Dispose()/DisposeAsync()directly because its inner type always implements both interfaces; this wrapper cannot make that assumption about IHostedService implementations, so it uses an is-check.Related Issue
Fixes #5651
Type of Change
Checklist
Required
TUnit-Specific Requirements
TUnit.AspNetCore.Core, notTUnit.Core.SourceGeneratororTUnit.Engine. Disposal forwarding is not test-discovery/execution metadata.PublishAot=true.Testing
Additional Notes
Regression tests: 7 pure-unit-tests in
HostedServiceDisposalForwardingTests.cscovering:DisposeAsync_Forwards_To_IAsyncDisposable_InnerDispose_Forwards_To_IDisposable_InnerDisposeAsync_On_SyncOnly_Inner_Falls_Back_To_DisposeDispose_On_AsyncOnly_Inner_Is_No_OpDisposeAsync_On_DualInterface_Inner_Prefers_Async_PathDispose_On_DualInterface_Inner_Prefers_Sync_PathWrapper_Does_Not_Throw_When_Inner_Is_Not_DisposableTests construct the wrapper directly (no
TestWebApplicationFactory, no host) to isolate the forwarding contract fromTestWebApplicationFactory's disposal-ordering behaviour, which is outside this fix's scope. Verified stable over 10 consecutive runs across net8.0, net9.0, and net10.0 (70/70 assertion successes per run, 210/210 across all three TFMs).The "never block on async" rule in
CLAUDE.mdis the reason the syncDisposepath does not attempt an async-to-sync bridge for async-only inners.Microsoft.Extensions.Hosting.Host.Disposeinternally usesDisposeAsync().AsTask().GetAwaiter().GetResult(), but that pattern is deliberately avoided here per the repo's stated convention — the documented no-op is the tradeoff.