Skip to content

feat: let a caller ask why a service is withheld, without throwing - #138

Merged
vbreuss merged 1 commit into
mainfrom
feat/withheld-reason
Aug 1, 2026
Merged

feat: let a caller ask why a service is withheld, without throwing#138
vbreuss merged 1 commit into
mainfrom
feat/withheld-reason

Conversation

@vbreuss

@vbreuss vbreuss commented Aug 1, 2026

Copy link
Copy Markdown
Member

A generated container already holds the message it would throw for a service it has but will not hand over synchronously: an async-initialized one reached through a synchronous path, or a disposable transient asked on the root under strict lifetime safety. Only Resolve could reach it. TryResolve and IsResolvable report such a service exactly as they report one with no registration at all, which is right for what each answers and useless for telling the two apart, so a host adapter that must answer an unknown type with null, because that is how it says "not mine" to a framework, had to turn the container's specific message into the framework's generic one.

IAwaitenContainerMetadata.WithheldReason returns that message, or null when there is no such reason. It is emitted over the same tables Resolve throws from, unkeyed and keyed alike, so the two answers are the same answer by construction rather than by agreement, and both Resolve overloads now read the one lookup. The member is emitted on the Root because the answer is the Root's: a child scope builds a root-withheld transient normally.

The bridge uses it, so GetService and GetKeyedService throw the guidance where they used to return null, and IsService and IsKeyedService report such a service as existing. That extends what the provider already did for a root-withheld disposable transient to the async-withheld case, on the same reasoning: a silent null lets a host bind the value from somewhere else and fail far from the cause. Four tests that pinned the old silence are inverted, and a probe sweep pins that the keyed pair still agree.

AWT135 gains a clause pointing a host-integration adapter at suppression, because "inject the dependency you need instead" is wrong advice for a type whose whole job is holding the resolver, and the sanctioned answer was documented only in the analyzer's own remarks. The diagnostics page gains the example.

Adding a member to IAwaitenContainerMetadata is source-breaking for a hand-written implementation, which the interface now says not to write; netstandard2.0 rules out a default implementation.

@vbreuss vbreuss self-assigned this Aug 1, 2026
@vbreuss vbreuss added the enhancement New feature or request label Aug 1, 2026
@vbreuss
vbreuss force-pushed the feat/withheld-reason branch from 5bbaf37 to 7ff9276 Compare August 1, 2026 10:50
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   9m 0s ⏱️ -11s
1 320 tests + 21  1 319 ✅ + 21  1 💤 ±0  0 ❌ ±0 
6 437 runs  +143  6 436 ✅ +143  1 💤 ±0  0 ❌ ±0 

Results for commit 282dc29. ± Comparison against base commit 58129e7.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 1, 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.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), 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 7.024 ns 0.0086 ns 0.0067 ns 0.94 - NA
Awaiten 8 7.505 ns 0.0037 ns 0.0031 ns 1.00 - NA
MsDI 8 7.313 ns 0.0055 ns 0.0049 ns 0.97 - NA
Autofac 8 145.095 ns 3.9202 ns 3.6670 ns 19.33 656 B NA
Jab 8 2.751 ns 0.0035 ns 0.0029 ns 0.37 - NA
PureDI 8 5.463 ns 0.0036 ns 0.0030 ns 0.73 - NA
DryIoc 8 8.721 ns 0.0092 ns 0.0081 ns 1.16 - NA
SimpleInjector 8 10.959 ns 0.0069 ns 0.0061 ns 1.46 - NA
baseline* 256 8.127 ns 0.0142 ns 0.0126 ns 1.00 - NA
Awaiten 256 8.152 ns 0.0147 ns 0.0115 ns 1.00 - NA
MsDI 256 7.351 ns 0.0068 ns 0.0057 ns 0.90 - NA
Autofac 256 144.809 ns 1.3377 ns 1.2512 ns 17.76 656 B NA
Jab 256 42.718 ns 0.0298 ns 0.0233 ns 5.24 - NA
PureDI 256 8.229 ns 0.0050 ns 0.0039 ns 1.01 - NA
DryIoc 256 8.768 ns 0.0327 ns 0.0273 ns 1.08 - NA
SimpleInjector 256 14.932 ns 0.0417 ns 0.0390 ns 1.83 - 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.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

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

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 247.2 ns 1.32 ns 1.23 ns 1.02 568 B 1.00
Awaiten 242.4 ns 4.26 ns 3.98 ns 1.00 568 B 1.00
MsDI 613.1 ns 3.42 ns 2.85 ns 2.53 1104 B 1.94
Autofac 6,413.7 ns 27.78 ns 24.63 ns 26.46 10784 B 18.99
Jab 174.9 ns 1.21 ns 1.07 ns 0.72 432 B 0.76
DryIoc 389.8 ns 3.00 ns 2.66 ns 1.61 944 B 1.66
SimpleInjector 709.9 ns 2.44 ns 2.28 ns 2.93 1096 B 1.93
PureDI 175.7 ns 1.35 ns 1.20 ns 0.73 632 B 1.11
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.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), 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.859 ns 0.3177 ns 0.2816 ns 0.93 144 B 1.00
Awaiten 8 19.16 ns 0.424 ns 0.397 ns 1.00 144 B 1.00
MsDI 8 1,568.24 ns 24.797 ns 23.195 ns 81.89 5688 B 39.50
Autofac 8 31,432.63 ns 67.645 ns 59.965 ns 1,641.30 33098 B 229.85
Jab 8 11.71 ns 0.488 ns 0.456 ns 0.61 96 B 0.67
PureDI 8 17.40 ns 0.313 ns 0.293 ns 0.91 128 B 0.89
DryIoc 8 725.08 ns 9.675 ns 8.577 ns 37.86 1528 B 10.61
SimpleInjector 8 13,194.32 ns 322.544 ns 301.708 ns 688.96 24761 B 171.95
baseline* 256 95.268 ns 1.1381 ns 0.9504 ns 0.84 2128 B 1.00
Awaiten 256 113.42 ns 3.804 ns 3.558 ns 1.00 2128 B 1.00
MsDI 256 15,867.43 ns 167.050 ns 148.086 ns 140.03 61016 B 28.67
Autofac 256 768,345.06 ns 2,277.709 ns 2,019.130 ns 6,780.61 740645 B 348.05
Jab 256 95.27 ns 7.320 ns 6.847 ns 0.84 2080 B 0.98
PureDI 256 115.84 ns 4.521 ns 4.229 ns 1.02 2112 B 0.99
DryIoc 256 45,366.76 ns 407.551 ns 381.223 ns 400.36 80640 B 37.89
SimpleInjector 256 406,081.78 ns 4,203.341 ns 3,931.808 ns 3,583.65 573028 B 269.28

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

A generated container already holds the message it would throw for a service it has but will not hand over synchronously: an async-initialized one reached through a synchronous path, or a disposable transient asked on the root under strict lifetime safety. Only Resolve could reach it. TryResolve and IsResolvable report such a service exactly as they report one with no registration at all, which is right for what each answers and useless for telling the two apart, so a host adapter that must answer an unknown type with null, because that is how it says "not mine" to a framework, had to turn the container's specific message into the framework's generic one.

IAwaitenContainerMetadata.WithheldReason returns that message, or null when there is no such reason. It is emitted over the same tables Resolve throws from, unkeyed and keyed alike, so the two answers are the same answer by construction rather than by agreement, and both Resolve overloads now read the one lookup. The member is emitted on the Root because the answer is the Root's: a child scope builds a root-withheld transient normally.

The lookup is emitted only when one of the tables it reads exists, so a container that withholds nothing reports the constant instead of carrying a helper whose every path returns null. Whether it does is decided once, where the __withheld table is emitted, and passed to the emitters that read it, so the decision to emit the table cannot drift from the decision to read it. The Root needs that answer and is emitted first, so the Scope is emitted into a buffer and the generated file is unchanged for every container that does withhold something.

The bridge uses it, so GetService and GetKeyedService throw the guidance where they used to return null, and IsService and IsKeyedService report such a service as existing. That extends what the provider already did for a root-withheld disposable transient to the async-withheld case, on the same reasoning: a silent null lets a host bind the value from somewhere else and fail far from the cause. Four tests that pinned the old silence are inverted, and a probe sweep pins that the keyed pair still agree.

AWT135 gains a clause pointing a host-integration adapter at suppression, because "inject the dependency you need instead" is wrong advice for a type whose whole job is holding the resolver, and the sanctioned answer was documented only in the analyzer's own remarks. The diagnostics page gains the example, and the example is compiled and covered rather than only written down, because it is the reference an adapter author copies. It honours the collection convention the bridge page asks of such an adapter: an unresolvable IEnumerable<T> enumerates empty rather than coming back null, unless the element type resolves, in which case members exist that an empty sequence would drop.

Adding a member to IAwaitenContainerMetadata is source-breaking for a hand-written implementation, which the interface now says not to write; netstandard2.0 rules out a default implementation.
@vbreuss
vbreuss force-pushed the feat/withheld-reason branch from 7ff9276 to 282dc29 Compare August 1, 2026 13:01
@sonarqubecloud

sonarqubecloud Bot commented Aug 1, 2026

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit 9103996 into main Aug 1, 2026
15 checks passed
@vbreuss
vbreuss deleted the feat/withheld-reason branch August 1, 2026 13:12
github-actions Bot added a commit that referenced this pull request Aug 1, 2026
… withheld, without throwing (#138) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Aug 1, 2026
… withheld, without throwing (#138) 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