Skip to content

feat: Async relationship types and async/owned disposal#36

Closed
vbreuss wants to merge 5 commits into
mainfrom
feat/async-relationship-types
Closed

feat: Async relationship types and async/owned disposal#36
vbreuss wants to merge 5 commits into
mainfrom
feat/async-relationship-types

Conversation

@vbreuss

@vbreuss vbreuss commented Jun 30, 2026

Copy link
Copy Markdown
Member

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 initializes T
  • Func<…, Task<T>> — an async factory that forwards runtime [Arg]s and awaits initialization
  • Lazy<Task<T>> — a memoized async dependency
  • Task<Owned<T>> / Func<…, Task<Owned<T>>> — the leak-free (per-use disposal) async counterparts of Owned<T> / Func<…, Owned<T>>

These defer resolution like Func<T>/Lazy<T>, so they launder async taint and do not trip AWT119/AWT120. The change also makes [Arg] + async-initialization a legal combination (removing AWT121), extends the leak guard (AWT118) to the new async factories, and adds additive IAsyncDisposable support that is gated by compilation detection rather than a forced dependency.

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.

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 / ClassifyRelationship recognize Task<T>, Func<…, Task<T>>, Lazy<Task<T>> (and the Owned forms), classified as new DependencyKinds (Task, FuncTask, LazyTask, with ProducesOwned).
  • Like the synchronous relationships they contribute no graph edge, so async taint is laundered exactly where resolution is deferred.
  • The emitter produces an async-tainted target through its async resolver (awaiting initialization) and wraps a synchronously-resolvable target with Task.FromResult.

Async parameterized resolver

  • Func<TArg…, Task<T>> over a parameterized async service forwards the runtime arguments and awaits InitializeAsync (or the async factory) — a single, correct initialization path rather than a completed Task over an uninitialized instance.
  • In pragmatic mode (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-resolve T into a throwaway child scope via a new __OwnedAsync<T> helper and return the Owned<T> disposal handle — built through the existing Owned<T> type, no new public type.

IAsyncDisposable support (additive, opt-in by target framework)

  • Gated on GetTypeByMetadataName("System.IAsyncDisposable"): emitted when the consumer's compilation can see the type (net5.0+/netstandard2.1+ in-box, or older targets that reference Microsoft.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.
  • The generated concrete Scope (inherited by Root) implements IAsyncDisposable with a newest-first DisposeAsync that awaits IAsyncDisposable.DisposeAsync and falls back to Dispose. This is intentionally not added to the IAwaitenScope interface (which would break every hand-implementer); the concrete-class implementation still enables await using.
  • Synchronous Dispose throws InvalidOperationException on an IAsyncDisposable-only service rather than blocking on async (matching Microsoft.Extensions.DependencyInjection).
  • Owned<T> gains an additive DisposeAsync() under #if for frameworks whose BCL has IAsyncDisposable; using/.Dispose() callers are unchanged.

Diagnostics

  • Removed AWT121 ("parameterized service cannot be async-initialized") — the combination is now legal through Func<TArg…, Task<T>>. It never shipped (AnalyzerReleases.Shipped.md is empty), so the id is freed cleanly. Misuse is caught at the consumption site instead: a synchronous Func<TArg…, T> is AWT119; a plain / Lazy<T> / Task<T> dependency supplying no arguments is AWT115.
  • AWT113 now also validates the runtime arguments of Func<TArg…, Task<T>>.
  • AWT115 message extended to include Task<T> dependencies.
  • AWT118 (root-accumulating factory) now matches FuncTask as well as Func: a root-held Func<…, 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 synchronous FuncFunc<…, Owned<T>>; an async Func<…, Task<T>> → explicitly scoped resolution, since Owned<T> cannot await).

Compatibility / public API

  • Additive only. The single public-surface change is Owned<T> gaining IAsyncDisposable on net8.0/net10.0; expected public-API snapshots are updated accordingly.
  • No new dependency; older frameworks without IAsyncDisposable are unaffected and remain sync-dispose-only.

…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.
@vbreuss vbreuss added the enhancement New feature or request label Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

Test Results

   18 files  ± 0     18 suites  ±0   1m 18s ⏱️ +6s
  262 tests +14    261 ✅ +14  1 💤 ±0  0 ❌ ±0 
1 303 runs  +71  1 302 ✅ +71  1 💤 ±0  0 ❌ ±0 

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.
Awaiten.SourceGenerators.Tests.AsyncFactoryTests ‑ ParameterizedAsyncFactory_EmitsNoBrokenCode_TheErrorStubReplacesResolution
Awaiten.SourceGenerators.Tests.AsyncFactoryTests ‑ TaskFactory_WithArgParameter_ReportsAwt121
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt121ParameterizedAsyncInitialization ‑ DoesNotReport_ForANonParameterizedAsyncInitializableService
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt121ParameterizedAsyncInitialization ‑ ReportsEvenInPragmaticMode
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt121ParameterizedAsyncInitialization ‑ ReportsWhenAParameterizedServiceIsAlsoAsyncInitializable
Awaiten.SourceGenerators.Tests.AsyncFactoryTests ‑ ParameterizedTaskFactory_ConsumedViaFuncOfTask_IsLegalAndAwaitsTheFactory
Awaiten.SourceGenerators.Tests.AsyncFactoryTests ‑ ParameterizedTaskFactory_ConsumedViaSyncFunc_ReportsAwt119
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ AsyncDisposableService_EmitsDisposeAsyncAndAnAwaitingDrain
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ AsyncOwned_FuncOfTaskOfOwned_AsyncResolvesIntoAThrowawayScopeAndIsExemptFromAwt118
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ AsyncRelationships_ResolveAnAsyncServiceWithoutTaintingAStrictConsumer
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ BareTaskOfAParameterizedService_ReportsAwt115
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ ParameterizedFuncOfTask_ForwardsArgumentsThroughTheAsyncResolver
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ ParameterizedFuncOfTask_WithMismatchedArguments_ReportsAwt113
Awaiten.SourceGenerators.Tests.AsyncRelationshipTypesTests ‑ PragmaticMode_ParameterizedAsync_SyncResolverForwardsArgumentsAndBlocksOnTheAsyncResolver
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt118RootAccumulatingFactory ‑ DoesNotReportForANonDisposableAsyncTransientFactory
…

♻️ This comment has been updated with latest results.

@vbreuss vbreuss changed the title feat: add async relationship types Task<T>, Func<…,Task<T>> and Lazy<Task<T>> feat: add async relationship types Task<T>, Func<…,Task<T>> and Lazy<Task<T>> Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.87GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 15.987 ns 0.0524 ns 0.0491 ns 1.12 - NA
Awaiten 8 14.307 ns 0.0122 ns 0.0114 ns 1.00 - NA
MsDI 8 8.896 ns 0.0130 ns 0.0115 ns 0.62 - NA
Autofac 8 106.488 ns 0.4229 ns 0.3956 ns 7.44 656 B NA
Jab 8 2.423 ns 0.1414 ns 0.1180 ns 0.17 - NA
PureDI 8 4.441 ns 0.0136 ns 0.0120 ns 0.31 - NA
DryIoc 8 9.027 ns 0.1875 ns 0.1754 ns 0.63 - NA
SimpleInjector 8 10.534 ns 0.1286 ns 0.1203 ns 0.74 - NA
baseline* 256 415.471 ns 0.1251 ns 0.1170 ns 0.90 - NA
Awaiten 256 462.481 ns 0.1980 ns 0.1755 ns 1.00 - NA
MsDI 256 8.682 ns 0.0107 ns 0.0089 ns 0.02 - NA
Autofac 256 107.737 ns 0.3976 ns 0.3525 ns 0.23 656 B NA
Jab 256 46.247 ns 0.0273 ns 0.0242 ns 0.10 - NA
PureDI 256 8.557 ns 0.0071 ns 0.0063 ns 0.02 - NA
DryIoc 256 9.089 ns 0.1551 ns 0.1375 ns 0.02 - NA
SimpleInjector 256 14.688 ns 0.2903 ns 0.2715 ns 0.03 - NA
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 242.6 ns 1.52 ns 1.42 ns 0.97 560 B 1.00
Awaiten 250.5 ns 1.64 ns 1.37 ns 1.00 560 B 1.00
MsDI 610.5 ns 5.27 ns 4.93 ns 2.44 1104 B 1.97
Autofac 8,550.6 ns 85.53 ns 80.00 ns 34.14 13696 B 24.46
Jab 181.2 ns 2.10 ns 1.97 ns 0.72 432 B 0.77
DryIoc 406.9 ns 7.18 ns 6.71 ns 1.62 944 B 1.69
SimpleInjector 731.5 ns 5.05 ns 4.48 ns 2.92 1096 B 1.96
PureDI 187.7 ns 2.01 ns 1.78 ns 0.75 632 B 1.13
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 17.345 ns 0.4427 ns 0.4141 ns 0.99 136 B 1.00
Awaiten 8 17.47 ns 0.488 ns 0.456 ns 1.00 136 B 1.00
MsDI 8 1,591.34 ns 37.154 ns 34.754 ns 91.13 5688 B 41.82
Autofac 8 29,572.40 ns 113.833 ns 106.479 ns 1,693.45 33098 B 243.37
Jab 8 14.55 ns 1.336 ns 1.250 ns 0.83 96 B 0.71
PureDI 8 16.27 ns 0.183 ns 0.171 ns 0.93 128 B 0.94
DryIoc 8 717.66 ns 5.362 ns 4.753 ns 41.10 1472 B 10.82
SimpleInjector 8 11,940.22 ns 255.634 ns 239.120 ns 683.75 24760 B 182.06
baseline* 256 77.933 ns 0.2794 ns 0.2477 ns 0.83 2120 B 1.00
Awaiten 256 93.73 ns 3.570 ns 3.339 ns 1.00 2120 B 1.00
MsDI 256 14,915.50 ns 268.951 ns 251.577 ns 159.32 61016 B 28.78
Autofac 256 708,971.04 ns 6,792.265 ns 6,353.489 ns 7,572.92 720127 B 339.68
Jab 256 76.59 ns 1.044 ns 0.977 ns 0.82 2080 B 0.98
PureDI 256 84.55 ns 0.660 ns 0.585 ns 0.90 2112 B 1.00
DryIoc 256 42,814.72 ns 160.517 ns 134.039 ns 457.33 81802 B 38.59
SimpleInjector 256 343,087.60 ns 1,740.866 ns 1,628.407 ns 3,664.71 572994 B 270.28

baseline* rows show the corresponding Awaiten benchmark from the most recent successful main branch build with results, for regression comparison.

@github-actions

Copy link
Copy Markdown

👽 Mutation Results

Mutation testing badge

Awaiten

Details
File Score Killed Survived Timeout No Coverage Ignored Compile Errors Runtime Errors Total Detected Total Undetected Total Mutants

The final mutation score is NaN%

Coverage Thresholds: high:80 low:60 break:0

vbreuss added 3 commits June 30, 2026 10:26
…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.
@vbreuss vbreuss changed the title feat: add async relationship types Task<T>, Func<…,Task<T>> and Lazy<Task<T>> feat: Async relationship types and async/owned disposal Jun 30, 2026
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
12 New issues

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@vbreuss vbreuss closed this Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant