feat: Async relationship types and async/owned disposal#36
Conversation
…Task<T>> Extend ClassifyParameter/ClassifyRelationship and the emitter so a service can depend on an async-initialized (or async-factory-produced) service through an awaitable relationship: Task<T> resolves and initializes T, Func<…,Task<T>> is an async factory forwarding runtime [Arg]s, and Lazy<Task<T>> is a memoized async dependency. These defer resolution like Func<T>/Lazy<T>, so they launder async taint - a synchronously-resolvable consumer can hold one over an async service without becoming async-tainted and without tripping AWT119/AWT120, which is the fix those diagnostics point to. An async-tainted target is produced through its async resolver (awaiting initialization); a synchronously-resolvable target is wrapped with Task.FromResult over its synchronous resolver. Add an async parameterized resolver so Func<TArg…,Task<T>> over a parameterized async service is correct rather than merely tolerated: it forwards the runtime arguments AND awaits InitializeAsync (or the async factory), instead of wrapping the synchronous resolver in a completed Task and silently skipping initialization. In pragmatic mode (SyncResolveAfterInit) the synchronous parameterized resolver block-delegates to it, so there is a single initialization path. AWT113 now also validates the runtime arguments of a Func<TArg…,Task<T>>. Remove AWT121 (parameterized service cannot be async-initialized). It existed only because there was no correct resolution path for an [Arg]-plus-async service until this async parameterized factory relationship; that path now exists, so the combination is legal when consumed through Func<TArg…,Task<T>>. Misuse is caught at the consumption site instead: a synchronous Func<TArg…,T> over such a service is AWT119, and a plain / Lazy<T> / Task<T> dependency that supplies no arguments is AWT115. AWT121 never shipped (AnalyzerReleases.Shipped.md is empty), so removing it frees the id with no released suppression able to latch onto it. ValueTask<T> is deliberately not a relationship type (a stored ValueTask may only be awaited once); it remains supported solely as an async factory's return type on the producer side.
Test Results 18 files ± 0 18 suites ±0 1m 18s ⏱️ +6s Results for commit 8bf6e37. ± Comparison against base commit 89a0188. This pull request removes 5 and adds 19 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Task<T>, Func<…,Task<T>> and Lazy<Task<T>>
🚀 Benchmark ResultsDetails
Details
Details
|
…build-on-demand service
The root-accumulating-factory check (AWT118) only matched DependencyKind.Func, so the new async factory relationship Func<…,Task<T>> slipped through. Its async resolver tracks freshly built disposables on the owner identically to the synchronous resolver, so a root-owned holder of Func<…,Task<T>> over a disposable transient (or one that transitively rebuilds a disposable transient) accumulates instances on the container root for its entire lifetime - the same unbounded leak AWT118 exists to catch. Because a synchronous Func/Lazy/Owned over an async-tainted service is AWT119, Func<…,Task<T>> is the only deferred factory that can reach an async service at all, so this was the one path on which the leak went unguarded - and under strict lifetime safety it silently bypassed what is otherwise a non-suppressible error. IsRootAccumulatingFunc now matches Func or FuncTask.
The leak-free remedy differs by relationship, so the AWT118 message now carries it as a {2} argument rather than hardcoding the Owned form. A synchronous Func is still redirected to Func<…,Owned<T>>; an async Func<…,Task<T>> cannot use Owned<T> - a synchronous handle that cannot await initialization, itself AWT119 - so it is pointed at an explicitly scoped resolution (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope), mirroring the existing bare-type async withholding guidance. Strict lifetime safety reports it as the same non-suppressible error as the synchronous case.
Add Docs/async-owned-disposal.md recording the staged roadmap this fix opens: Stage 1 (this change), Stage 2 (Func<…,Task<Owned<T>>> reusing the existing Owned<T>, gated to IDisposable targets), and Stage 3 (the IAsyncDisposable disposal pipeline plus an additive Owned<T>.DisposeAsync()). Each stage is additive on the last; the note records why none of them block IAsyncDisposable and why a sync-over-async Dispose() shim is the one choice that would.
…ncDisposable support
Add the async leak-free factory relationships Task<Owned<T>> and Func<…,Task<Owned<T>>> - the awaitable counterparts of Owned<T> / Func<…,Owned<T>>. Each async-resolves (and initializes) T into a throwaway child scope through a new __OwnedAsync<T> helper and hands back the Owned<T> disposal handle, so a synchronously-resolvable consumer can build async-initialized, disposable services on demand without the root accumulating them. They are classified in ClassifyParameter/ClassifyRelationship as the Task/FuncTask kinds with ProducesOwned, validated by AWT113, and resolved through the existing Owned<T> type - no new public type. The AWT118 message and AsyncRootWithheldMessage now point at Func<…,Task<Owned<T>>> as the leak-free async remedy. This resolution path needs no IAsyncDisposable and ships on every target framework with synchronous IDisposable disposal of the handle.
Add IAsyncDisposable support as an additive, net8.0+/polyfilled-only capability gated by compilation detection rather than a forced dependency. The generator checks GetTypeByMetadataName("System.IAsyncDisposable"): when the consumer's compilation can see the type (net5.0+/netstandard2.1+ in-box, or an older target that referenced Microsoft.Bcl.AsyncInterfaces) it emits the async-disposal machinery; when absent (e.g. net48 without the polyfill) the container is synchronous-dispose only and references no IAsyncDisposable, so it still compiles. No dependency was added to the library. IAsyncDisposable is folded into the disposal decision (InstanceModel.NeedsDisposal = IsDisposable || IsAsyncDisposable), which also flows into the leak analyses (AWT118 / by-type withholding / BuildsFreshDisposable); tracked instances share the existing List<object> and the drain pattern-matches at runtime.
The generated concrete Scope (inherited by the Root) implements IAsyncDisposable and gets a DisposeAsync that drains newest-first, awaiting IAsyncDisposable.DisposeAsync and falling back to Dispose. This is deliberately not added to the IAwaitenScope interface, which would break every hand-implementer (the MS.DI adapter, test doubles, external code); a concrete-class implementation gives await using on the container/scope without that break. The synchronous Dispose throws InvalidOperationException when it meets an IAsyncDisposable-only service rather than blocking on an async dispose (matching Microsoft.Extensions.DependencyInjection); the raced-during-construction teardown was restructured to flag-then-tear-down outside the lock so the async path can await, and its runtime checks go through (object)created so a sealed type implementing only one disposal interface still compiles. Owned<T> implements IAsyncDisposable under a #if for the frameworks whose BCL has it, additively (using/.Dispose() callers are unchanged), routing DisposeAsync to the backing scope.
Docs/async-owned-disposal.md records the implemented design and the decisions (net8.0+ detection over a dependency, concrete Scope over the interface, sync Dispose throws over sync-over-async). Expected public-API snapshots updated for the additive Owned<T> change on net8.0/net10.0.
…usions Two comments in Emitter.cs predate async relationship types and no longer describe the code. EmitAsyncResolutionApi claimed a parameterized service "has no async resolver of its own" - it does now (Func<TArg…, Task<T>> binds the async parameterized resolver); it simply has no by-type ResolveAsync entry because by-type resolution cannot supply the [Arg]s. AsyncWithheldServices excludes parameterized services with no stated reason; document that the exclusion is intentional - a parameterized service is never bare-type resolvable (it needs its arguments through a Func), so the "resolve through ResolveAsync" guidance would not fit, and its unavailability is governed by parameterization rather than asynchronous initialization. Comment-only; no behavior change.
Task<T>, Func<…,Task<T>> and Lazy<Task<T>>
|




Summary
Adds awaitable relationship types to the Awaiten DI container so a synchronously-resolvable service can depend on an async-initialized (or async-factory-produced) service without itself becoming async-tainted:
Task<T>— an awaitable that resolves and initializesTFunc<…, Task<T>>— an async factory that forwards runtime[Arg]s and awaits initializationLazy<Task<T>>— a memoized async dependencyTask<Owned<T>>/Func<…, Task<Owned<T>>>— the leak-free (per-use disposal) async counterparts ofOwned<T>/Func<…, Owned<T>>These defer resolution like
Func<T>/Lazy<T>, so they launder async taint and do not tripAWT119/AWT120. The change also makes[Arg]+ async-initialization a legal combination (removingAWT121), extends the leak guard (AWT118) to the new async factories, and adds additiveIAsyncDisposablesupport that is gated by compilation detection rather than a forced dependency.ValueTask<T>is deliberately not a relationship type (a storedValueTaskmay only be awaited once); it remains supported solely as an async factory's return type on the producer side.Motivation
Before this change, an
[Arg]-parameterized service could not be async-initialized at all (AWT121), and a synchronously-resolvable consumer had no way to hold an async service without becoming async-tainted. The only deferral options (Func/Lazy/Owned) are synchronous and cannot await initialization. The awaitable relationship types close that gap and give a correct resolution path for every[Arg]+ async combination.What changed
New relationship kinds
ClassifyParameter/ClassifyRelationshiprecognizeTask<T>,Func<…, Task<T>>,Lazy<Task<T>>(and theOwnedforms), classified as newDependencyKinds (Task,FuncTask,LazyTask, withProducesOwned).Task.FromResult.Async parameterized resolver
Func<TArg…, Task<T>>over a parameterized async service forwards the runtime arguments and awaitsInitializeAsync(or the async factory) — a single, correct initialization path rather than a completedTaskover an uninitialized instance.SyncResolveAfterInit) the synchronous parameterized resolver block-delegates to the async one, so there is still one init path.Async owned (leak-free) relationships
Task<Owned<T>>/Func<…, Task<Owned<T>>>async-resolveTinto a throwaway child scope via a new__OwnedAsync<T>helper and return theOwned<T>disposal handle — built through the existingOwned<T>type, no new public type.IAsyncDisposablesupport (additive, opt-in by target framework)GetTypeByMetadataName("System.IAsyncDisposable"): emitted when the consumer's compilation can see the type (net5.0+/netstandard2.1+ in-box, or older targets that referenceMicrosoft.Bcl.AsyncInterfaces); fully absent otherwise (e.g. net48 without the polyfill) so the container stays sync-dispose-only and still compiles. No dependency added to the library.Scope(inherited byRoot) implementsIAsyncDisposablewith a newest-firstDisposeAsyncthat awaitsIAsyncDisposable.DisposeAsyncand falls back toDispose. This is intentionally not added to theIAwaitenScopeinterface (which would break every hand-implementer); the concrete-class implementation still enablesawait using.DisposethrowsInvalidOperationExceptionon anIAsyncDisposable-only service rather than blocking on async (matchingMicrosoft.Extensions.DependencyInjection).Owned<T>gains an additiveDisposeAsync()under#iffor frameworks whose BCL hasIAsyncDisposable;using/.Dispose()callers are unchanged.Diagnostics
AWT121("parameterized service cannot be async-initialized") — the combination is now legal throughFunc<TArg…, Task<T>>. It never shipped (AnalyzerReleases.Shipped.mdis empty), so the id is freed cleanly. Misuse is caught at the consumption site instead: a synchronousFunc<TArg…, T>isAWT119; a plain /Lazy<T>/Task<T>dependency supplying no arguments isAWT115.AWT113now also validates the runtime arguments ofFunc<TArg…, Task<T>>.AWT115message extended to includeTask<T>dependencies.AWT118(root-accumulating factory) now matchesFuncTaskas well asFunc: a root-heldFunc<…, Task<T>>over a disposable build-on-demand service accumulates instances on the root for the container's lifetime — the same unbounded leak — and previously slipped through. The message now carries the leak-free remedy as an argument (a synchronousFunc→Func<…, Owned<T>>; an asyncFunc<…, Task<T>>→ explicitly scoped resolution, sinceOwned<T>cannot await).Compatibility / public API
Owned<T>gainingIAsyncDisposableon net8.0/net10.0; expected public-API snapshots are updated accordingly.IAsyncDisposableare unaffected and remain sync-dispose-only.