Skip to content

docs: document that DI scope Dispose() throws for async-only disposables, recommend DisposeAsync()#124966

Draft
Copilot wants to merge 2 commits intomainfrom
copilot/fix-di-scope-disposal
Draft

docs: document that DI scope Dispose() throws for async-only disposables, recommend DisposeAsync()#124966
Copilot wants to merge 2 commits intomainfrom
copilot/fix-di-scope-disposal

Conversation

Copy link
Contributor

Copilot AI commented Feb 27, 2026

ServiceProviderEngineScope.Dispose() throws InvalidOperationException (or AggregateException for multiple offenders) when any resolved service implements only IAsyncDisposable. This behavior is undocumented, making it a surprising pitfall — especially when a new dependency introduces an async-only disposable service into an existing sync disposal flow.

Changes

  • ServiceProvider.Dispose() / DisposeAsync() — replaced /// <inheritdoc /> with explicit docs: Dispose() documents both exception types (InvalidOperationException for one, AggregateException for multiple async-only services) and recommends DisposeAsync(); DisposeAsync() is documented as the preferred method
  • AsyncServiceScope.Dispose() / DisposeAsync() — same treatment; DisposeAsync() remarks correctly qualify that the no-throw guarantee only holds when the underlying scope implements IAsyncDisposable
  • IServiceScope remarks — added second paragraph pointing out the disposal restriction and recommending AsyncServiceScope to ensure async disposal is always invoked
// Using statement on an IServiceScope can silently detonate if any
// registered service is IAsyncDisposable-only:
using var scope = provider.CreateScope(); // Dispose() throws InvalidOperationException

// Prefer AsyncServiceScope to guarantee DisposeAsync() is called:
await using var scope = new AsyncServiceScope(provider.CreateScope());
Original prompt

This section details on the original issue you should resolve

<issue_title>DI Scope Should Dispose All Disposables On Dispose()</issue_title>
<issue_description>Proposal options:

  1. Dispose should do sync-over-async
  2. Update documentation that we throw from Dispose when we should call async

When Dispose() is invoked on ServiceProviderEngineScope, the engine correctly disposes all members implementing IDisposable. However, if any member implements IAsyncDisposable but not IDisposable, an exception is thrown, effectively forcing consumers to call DisposeAsync() instead.

I presume the motivation here was well‑intentioned, encouraging callers to prefer DisposeAsync(), thereby avoiding sync‑over‑async execution paths, which are generally undesirable. Since DisposeAsync() returns a ValueTask, it can indeed be regarded as the superior mechanism, however, Dispose() is not marked as deprecated, discouraged, or otherwise inferior, and there is no warning indicating that invoking it may result in an exception, as highlighted by @CarnaViire in this comment. The current behaviour risks surprising consumers and may obstruct their workflow, especially when they do not explicitly control the disposal flow.

Consider a system that has historically relied solely on IDisposable services. Introducing a new library that happens to register an IAsyncDisposable component would suddenly produce exceptions out of the blue, effectively compelling system owners to perform a broader refactor simply to adopt the library.

Similarly, if a library calls Dispose() on a scope rather than DisposeAsync(), its consumers would be unable to use any library that registers a type implementing only IAsyncDisposable. This is problematic because implementing only IAsyncDisposable is entirely legitimate, and the official guidance explicitly states that a type may implement both interfaces only when relevant to handling both synchronous and asynchronous disposal flows, not as a general requirement.

I noticed a comment on this behaviour in the original PR. However, the resolution appeared to focus primarily on the breaking‑change aspect, rather than evaluating the broader behavioural correctness of the approach.

@davidfowl, @halter73, I noticed you both contributed to the earlier discussion, and I’m curious what your view is on the behaviour described above. Should Dispose() ultimately support a sync‑over‑async path, or should we at least add a clear warning to the documentation to make this expectation explicit, if the current design is intentional?</issue_description>

<agent_instructions>Improve the relevant Dispose method to indicate that it throws if any service implements only IAsyncDisposable as we disencourage using sync-over-async and in general recommend to always use DisposeAsync with explicit sync-over-async on user side if they really need to do it.</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@svick In either case, I think the documentation of both `Dispose` and `DisposeAsync` should be updated. The generic inherited message that's there right now is not very useful. @rosebyte Triage: we will improve documentation.

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

…disposable behavior

Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix DI scope to dispose all disposables on Dispose() docs: document that DI scope Dispose() throws for async-only disposables, recommend DisposeAsync() Feb 27, 2026
@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-dependencyinjection
See info in area-owners.md if you want to be subscribed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DI Scope Should Dispose All Disposables On Dispose()

2 participants