Skip to content

feat: add async factory methods as a Task<T> async-init registration channel#34

Merged
vbreuss merged 3 commits into
mainfrom
feat/async-factory
Jun 29, 2026
Merged

feat: add async factory methods as a Task<T> async-init registration channel#34
vbreuss merged 3 commits into
mainfrom
feat/async-factory

Conversation

@vbreuss

@vbreuss vbreuss commented Jun 29, 2026

Copy link
Copy Markdown
Member

Adds a second, statically-visible async-registration channel parallel to IAsyncInitializable: a Factory method may return Task<T> or ValueTask<T> and is matched against the unwrapped result type T. An async-returning factory is an async-taint source independent of whether T implements IAsyncInitializable, so the container reaches its result only by awaiting the factory call. This lets a factory author declare async-ness honestly without leaking the concrete type, and makes the synchronous path structurally impossible (a Task cannot be unwrapped synchronously) so AWT119 and strict withholding fall out for free with no special-casing.

ContainerRegistrations.FindFactoryCandidates now matches the AWT108 "returns the registered type" check against the unwrapped T (ProducedType / IsAsyncFactoryReturn), and BuildInstance computes disposability and IAsyncInitializable from that same produced T rather than the Task. The new InstanceModel.IsAsyncFactory seeds PropagateAsyncTaint alongside IsAsyncInitializable, and InstanceModel.IsAsyncSource generalizes the AWT119-vs-AWT120 split and the AWT121 parameterized guard to either taint source. The emitter awaits the factory call (await Create(...).ConfigureAwait(false)) only on the async construction path, which is the only path an async-tainted service is reached on; the existing memoized-Task machinery caches the awaited result for singletons and scoped services, and a transient async-factory is async-only.

ValueTask<T> is included: it is matched by canonical metadata symbol (absent on netstandard2.0, where GetTypeByMetadataName returns null and the branch is skipped), and the emitted await is uniform for Task and ValueTask so no TFM gating of generated code is needed. A parameterized async factory (Task<T> with [Arg] parameters) reuses the broadened AWT121, since it is reachable only through a synchronous Func<TArg..., T> that cannot await.

@vbreuss vbreuss self-assigned this Jun 29, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jun 29, 2026
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   1m 4s ⏱️ +5s
  235 tests + 22    234 ✅ + 22  1 💤 ±0  0 ❌ ±0 
1 193 runs  +102  1 192 ✅ +102  1 💤 ±0  0 ❌ ±0 

Results for commit 979945f. ± Comparison against base commit d00d448.

♻️ This comment has been updated with latest results.

@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

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

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

Resolve Size Mean Error StdDev Median Ratio Allocated Alloc Ratio
baseline* 8 13.329 ns 0.0109 ns 0.0085 ns 1.00 0.99 NA
Awaiten 8 13.523 ns 0.0141 ns 0.0110 ns 13.523 ns 1.00 - NA
MsDI 8 7.848 ns 0.4058 ns 0.3796 ns 7.660 ns 0.58 - NA
Autofac 8 110.470 ns 1.3149 ns 1.1656 ns 110.532 ns 8.17 656 B NA
Jab 8 3.140 ns 0.0114 ns 0.0106 ns 3.137 ns 0.23 - NA
PureDI 8 5.554 ns 0.1217 ns 0.1138 ns 5.640 ns 0.41 - NA
DryIoc 8 9.012 ns 0.1546 ns 0.1207 ns 9.063 ns 0.67 - NA
SimpleInjector 8 11.218 ns 0.0380 ns 0.0337 ns 11.204 ns 0.83 - NA
baseline* 256 421.271 ns 0.5956 ns 0.4650 ns 1.00 1.00 NA
Awaiten 256 419.255 ns 0.1116 ns 0.0932 ns 419.237 ns 1.00 - NA
MsDI 256 7.811 ns 0.3488 ns 0.3263 ns 7.763 ns 0.02 - NA
Autofac 256 115.283 ns 3.2326 ns 3.0237 ns 116.035 ns 0.27 656 B NA
Jab 256 42.685 ns 0.0338 ns 0.0282 ns 42.685 ns 0.10 - NA
PureDI 256 8.269 ns 0.0069 ns 0.0058 ns 8.269 ns 0.02 - NA
DryIoc 256 8.864 ns 0.1729 ns 0.1617 ns 8.727 ns 0.02 - NA
SimpleInjector 256 14.644 ns 0.0098 ns 0.0082 ns 14.643 ns 0.03 - NA
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.75GHz, 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* 243.8 ns 1.42 ns 1.26 ns 0.99 560 B 1.00
Awaiten 245.8 ns 7.30 ns 6.83 ns 1.00 560 B 1.00
MsDI 611.6 ns 7.45 ns 6.97 ns 2.49 1104 B 1.97
Autofac 7,951.7 ns 47.41 ns 44.35 ns 32.37 13696 B 24.46
Jab 172.7 ns 1.57 ns 1.47 ns 0.70 432 B 0.77
DryIoc 389.4 ns 1.98 ns 1.85 ns 1.58 944 B 1.69
SimpleInjector 707.5 ns 2.25 ns 1.88 ns 2.88 1096 B 1.96
PureDI 170.2 ns 1.03 ns 0.91 ns 0.69 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.020 ns 0.4060 ns 0.3390 ns 1.04 136 B 1.00
Awaiten 8 16.444 ns 0.2659 ns 0.2487 ns 1.00 136 B 1.00
MsDI 8 1,453.476 ns 20.9787 ns 18.5971 ns 88.41 5688 B 41.82
Autofac 8 28,923.464 ns 169.0194 ns 158.1008 ns 1,759.31 33098 B 243.37
Jab 8 8.580 ns 0.0587 ns 0.0491 ns 0.52 96 B 0.71
PureDI 8 15.549 ns 0.4695 ns 0.4392 ns 0.95 128 B 0.94
DryIoc 8 1,004.693 ns 10.5597 ns 8.8178 ns 61.11 1472 B 10.82
SimpleInjector 8 11,562.121 ns 172.3497 ns 161.2160 ns 703.28 24761 B 182.07
baseline* 256 80.789 ns 2.1866 ns 2.0454 ns 0.88 2120 B 1.00
Awaiten 256 92.157 ns 3.9522 ns 3.5035 ns 1.00 2120 B 1.00
MsDI 256 15,751.202 ns 928.1645 ns 868.2057 ns 171.14 61016 B 28.78
Autofac 256 694,051.578 ns 4,775.1373 ns 4,466.6664 ns 7,541.07 739590 B 348.86
Jab 256 81.404 ns 2.5806 ns 2.2877 ns 0.88 2080 B 0.98
PureDI 256 123.409 ns 4.2904 ns 4.0133 ns 1.34 2112 B 1.00
DryIoc 256 45,917.417 ns 748.9750 ns 700.5917 ns 498.91 80413 B 37.93
SimpleInjector 256 383,059.111 ns 6,181.7920 ns 5,782.4521 ns 4,162.05 573073 B 270.32

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

…channel

Adds a second, statically-visible async-registration channel parallel to IAsyncInitializable: a Factory method may return Task<T> or ValueTask<T> and is matched against the unwrapped result type T. An async-returning factory is an async-taint source independent of whether T implements IAsyncInitializable, so the container reaches its result only by awaiting the factory call. This lets a factory author declare async-ness honestly without leaking the concrete type, and makes the synchronous path structurally impossible (a Task cannot be unwrapped synchronously) so AWT119 and strict withholding fall out for free with no special-casing.

ContainerRegistrations.FindFactoryCandidates now matches the AWT108 "returns the registered type" check against the unwrapped T (ProducedType / IsAsyncFactoryReturn), and BuildInstance computes disposability and IAsyncInitializable from that same produced T rather than the Task. The new InstanceModel.IsAsyncFactory seeds PropagateAsyncTaint alongside IsAsyncInitializable, and InstanceModel.IsAsyncSource generalizes the AWT119-vs-AWT120 split and the AWT121 parameterized guard to either taint source. The emitter awaits the factory call (await Create(...).ConfigureAwait(false)) only on the async construction path, which is the only path an async-tainted service is reached on; the existing memoized-Task machinery caches the awaited result for singletons and scoped services, and a transient async-factory is async-only.

ValueTask<T> is included: it is matched by canonical metadata symbol (absent on netstandard2.0, where GetTypeByMetadataName returns null and the branch is skipped), and the emitted await is uniform for Task and ValueTask so no TFM gating of generated code is needed. A parameterized async factory (Task<T> with [Arg] parameters) reuses the broadened AWT121, since it is reachable only through a synchronous Func<TArg..., T> that cannot await.
@vbreuss vbreuss force-pushed the feat/async-factory branch from b6df015 to b6962f5 Compare June 29, 2026 13:22
vbreuss added 2 commits June 29, 2026 16:10
…ethods

A factory method may now take a System.Threading.CancellationToken parameter; the container forwards the resolve-time token (the async creator's cancellationToken; default on a synchronous path) instead of treating it as a graph dependency. The new DependencyKind.CancellationToken contributes no dependency edge and no async taint, mirroring [Arg], and is excluded from the AWT101 missing-dependency check. Forwarding is scoped to factory methods - a constructor has no ambient resolve-time token, so its CancellationToken parameter stays an ordinary (and therefore unregistered) dependency.

Also tighten the async-factory surface from review: the synchronous-resolution guidance message now names the async Task<T> factory channel alongside IAsyncInitializable, and a runtime test covers disposal of an async-factory-produced IDisposable hidden behind a non-disposable service interface (previously only asserted at the generated-source level).
…educe complexity

Restrict resolve-time CancellationToken forwarding to asynchronous factory methods. Only an async factory is constructed on the async path where that token is in scope; a synchronous factory (like a constructor) has no ambient token, so a forwarded CancellationToken would always have received default silently. Such a parameter now stays an ordinary graph dependency and is reported as AWT101 when unregistered rather than compiling to a silent default.

Also resolve two maintainability findings. Extract the Lazy/Func relationship classification out of ClassifyParameter into a ClassifyRelationship helper, dropping its cognitive complexity well below the threshold while preserving behavior on every path. And remove the now-dead synchronous branch of the emitted CancellationToken argument: a CancellationToken dependency only ever arises from an async factory, which is built solely on the async path, so the argument is always the in-scope token.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss vbreuss merged commit 14c9c20 into main Jun 29, 2026
14 checks passed
@vbreuss vbreuss deleted the feat/async-factory branch June 29, 2026 15:04
github-actions Bot added a commit that referenced this pull request Jun 29, 2026
…sk<T> async-init registration channel (#34) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jun 29, 2026
…sk<T> async-init registration channel (#34) by Valentin Breuß
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