Skip to content

feat: answer IServiceProviderIsService from the container metadata - #133

Merged
vbreuss merged 4 commits into
mainfrom
feat/service-provider-is-service
Jul 31, 2026
Merged

feat: answer IServiceProviderIsService from the container metadata#133
vbreuss merged 4 commits into
mainfrom
feat/service-provider-is-service

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 31, 2026

Copy link
Copy Markdown
Member

ASP.NET Core consults IServiceProviderIsService to decide whether a parameter comes from dependency injection or from the request, and MVC's controller activation consults it too. AwaitenServiceProvider did not offer it, so on the provider-replacement path a handler taking a container service was bound from the wrong place instead of failing. It now answers both that and IServiceProviderIsKeyedService from IAwaitenContainerMetadata.Registrations, constructing nothing.

The container dispatches many more shapes than it advertises as registrations, and the rules are not obvious, so each is derived from what the container actually does rather than from a reading of the generator:

  • a synchronously resolvable registration, and Task<T> for an async-only one
  • the six collection interfaces, a vector array and IAsyncEnumerable<T> over a resolvable element type, suppressed when a collection shape of that element type is registered unkeyed
  • Task< collection >, which does not require the members to be synchronous
  • IReadOnlyDictionary<TKey, T> for a string or enum key, requiring every keyed registration to carry exactly that key type

ProbeAgreement asserts the invariant that makes the probe worth having — that it agrees with GetService — over 22 shapes and 13 containers, on the root and on a scope. That harness found four defects during development that reasoning had missed, including two where the probe promised a service the container withholds.

Three divergences remain, each pinned by a test in AcceptedDivergences so it cannot drift silently: a collection whose members are not all synchronous is over-reported, a disposable transient is over-reported on the root, and a variance-compatible closing of a generic interface is under-reported. The first is not fixable in the bridge — Registrations coalesces the implementations of one service type into a single entry, so a container with one synchronous implementation and one that adds an async-initialized sibling advertise byte-identical metadata and resolve differently. Closing the first and third needs the generator to advertise the shapes it dispatches, which it builds at compile time already.

The projection path is unaffected: MS.DI stays the provider there and supplies its own implementation.

ASP.NET Core consults `IServiceProviderIsService` to decide whether a parameter
comes from dependency injection or from the request, and MVC's controller
activation consults it too. `AwaitenServiceProvider` did not offer it, so on the
provider-replacement path a handler taking a container service was bound from the
wrong place instead of failing. It now answers both that and
`IServiceProviderIsKeyedService` from `IAwaitenContainerMetadata.Registrations`,
constructing nothing.

The container dispatches many more shapes than it advertises as registrations, and
the rules are not obvious, so each is derived from what the container actually does
rather than from a reading of the generator:

- a synchronously resolvable registration, and `Task<T>` for an async-only one
- the six collection interfaces, a vector array and `IAsyncEnumerable<T>` over a
  resolvable element type, suppressed when a collection shape of that element type
  is registered unkeyed
- `Task<` collection `>`, which does not require the members to be synchronous
- `IReadOnlyDictionary<TKey, T>` for a `string` or enum key, requiring every keyed
  registration to carry exactly that key type

`ProbeAgreement` asserts the invariant that makes the probe worth having — that it
agrees with `GetService` — over 22 shapes and 13 containers, on the root and on a
scope. That harness found four defects during development that reasoning had
missed, including two where the probe promised a service the container withholds.

Three divergences remain, each pinned by a test in `AcceptedDivergences` so it
cannot drift silently: a collection whose members are not all synchronous is
over-reported, a disposable transient is over-reported on the root, and a
variance-compatible closing of a generic interface is under-reported. The first is
not fixable in the bridge — `Registrations` coalesces the implementations of one
service type into a single entry, so a container with one synchronous
implementation and one that adds an async-initialized sibling advertise
byte-identical metadata and resolve differently. Closing the first and third needs
the generator to advertise the shapes it dispatches, which it builds at compile
time already.

The projection path is unaffected: MS.DI stays the provider there and supplies its
own implementation.
@vbreuss vbreuss self-assigned this Jul 31, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   9m 5s ⏱️ -10s
1 280 tests + 38  1 279 ✅ + 38  1 💤 ±0  0 ❌ ±0 
6 161 runs  +266  6 160 ✅ +266  1 💤 ±0  0 ❌ ±0 

Results for commit 1e92bc8. ± Comparison against base commit c9cce64.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 31, 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.237 ns 0.1052 ns 0.0984 ns 1.04 1.00 -
Awaiten 8 6.979 ns 0.0032 ns 0.0027 ns 1.00 - NA
MsDI 8 7.353 ns 0.0072 ns 0.0064 ns 1.05 - NA
Autofac 8 140.685 ns 4.2587 ns 3.9836 ns 20.16 656 B NA
Jab 8 2.653 ns 0.0067 ns 0.0059 ns 0.38 - NA
PureDI 8 5.421 ns 0.0077 ns 0.0069 ns 0.78 - NA
DryIoc 8 9.078 ns 0.0208 ns 0.0174 ns 1.30 - NA
SimpleInjector 8 10.890 ns 0.0049 ns 0.0041 ns 1.56 - NA
baseline* 256 7.428 ns 0.1472 ns 0.1305 ns 0.91 1.00 -
Awaiten 256 8.132 ns 0.0093 ns 0.0082 ns 1.00 - NA
MsDI 256 7.316 ns 0.0093 ns 0.0082 ns 0.90 - NA
Autofac 256 140.685 ns 3.5101 ns 3.2833 ns 17.30 656 B NA
Jab 256 42.690 ns 0.0631 ns 0.0527 ns 5.25 - NA
PureDI 256 8.273 ns 0.0062 ns 0.0052 ns 1.02 - NA
DryIoc 256 9.209 ns 0.0119 ns 0.0105 ns 1.13 - NA
SimpleInjector 256 14.513 ns 0.1241 ns 0.1161 ns 1.78 - NA
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.66GHz, 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* 241.2 ns 3.42 ns 3.20 ns 1.03 568 B 1.00
Awaiten 234.7 ns 1.51 ns 1.26 ns 1.00 568 B 1.00
MsDI 598.1 ns 3.15 ns 2.63 ns 2.55 1104 B 1.94
Autofac 6,687.7 ns 27.30 ns 22.80 ns 28.50 10784 B 18.99
Jab 178.0 ns 1.21 ns 1.01 ns 0.76 432 B 0.76
DryIoc 396.5 ns 1.56 ns 1.30 ns 1.69 944 B 1.66
SimpleInjector 723.8 ns 9.68 ns 9.06 ns 3.08 1096 B 1.93
PureDI 180.2 ns 1.97 ns 1.64 ns 0.77 632 B 1.11
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 3.06GHz, 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 24.04 ns 0.435 ns 0.386 ns 1.48 144 B 1.00
Awaiten 8 16.216 ns 0.1314 ns 0.1165 ns 1.00 144 B 1.00
MsDI 8 1,418.599 ns 9.0984 ns 8.0655 ns 87.48 5688 B 39.50
Autofac 8 30,593.903 ns 154.8015 ns 144.8014 ns 1,886.71 33098 B 229.85
Jab 8 9.047 ns 0.1439 ns 0.1346 ns 0.56 96 B 0.67
PureDI 8 14.522 ns 0.0715 ns 0.0668 ns 0.90 128 B 0.89
DryIoc 8 711.241 ns 1.7489 ns 1.4604 ns 43.86 1528 B 10.61
SimpleInjector 8 11,364.674 ns 24.8714 ns 19.4180 ns 700.85 24761 B 171.95
baseline* 256 190.29 ns 9.149 ns 8.558 ns 2.11 2128 B 1.00
Awaiten 256 90.166 ns 0.1737 ns 0.1539 ns 1.00 2128 B 1.00
MsDI 256 14,130.654 ns 61.4391 ns 54.4641 ns 156.72 61016 B 28.67
Autofac 256 735,640.509 ns 10,381.5989 ns 9,710.9540 ns 8,158.73 737836 B 346.73
Jab 256 72.942 ns 0.7061 ns 0.6259 ns 0.81 2080 B 0.98
PureDI 256 79.817 ns 0.1288 ns 0.1142 ns 0.89 2112 B 0.99
DryIoc 256 42,421.433 ns 143.0935 ns 133.8497 ns 470.48 79372 B 37.30
SimpleInjector 256 342,934.504 ns 1,871.9860 ns 1,659.4669 ns 3,803.37 573057 B 269.29

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

vbreuss added 3 commits July 31, 2026 17:23
The feature-detection review turned up three gaps in what the probe's
contract is actually held to:

- An open-generic closing that only a framework asks for is expanded per
  closing the container's own graph consumes, so it is neither advertised
  nor resolvable. The probe stays faithful to the container, but this is
  a fourth divergence from MS.DI (which answers true) and it lands on the
  silent side: a minimal API told false binds the parameter from the
  request body. Pinned in AcceptedDivergences and written up in the docs.
- IServiceProviderIsKeyedService had no differential harness at all;
  ProbeAgreement only ever swept IsService. It now takes a key and
  compares against GetKeyedService, and the keyed surface is swept over
  the same wide shape list, including the keyed Task<T> projection.
- SyncResolveAfterInit is the one input the RequiresAsync rules read, and
  nothing exercised it. A warmed container now pins the swap: the bare
  type becomes the service, its Task<T> projection stops being one.

Registrations advertises user-keyed entries too, and has since the keyed
resolution path, but its summary still said unkeyed - now load-bearing
for the whole keyed probe, so it is corrected.

Also drops IServiceProviderIsService from the provider's base list, which
IServiceProviderIsKeyedService already derives from, matching how
IKeyedServiceProvider covers IServiceProvider there. No API change.
The bridge inferred the probe's answer from `Registrations`, re-deriving the
generator's synthesis rules in about 150 lines. That inference was where every
defect in this feature lived, and two of its answers were not derivable at all:
registrations coalesce, so collection members cannot be counted, and the runtime
variance fallback resolves closings no registration mentions.

`IAwaitenContainerMetadata` gains `IsResolvable(Type, object?)`, emitted from the
same dispatch tables `TryResolve` reads, minus the invocation. Exact by
construction rather than by inference, so it cannot drift from what resolution
does. The bridge now delegates and the inference layer is gone.

Three divergences close as a result:

- a collection whose members are not all synchronously initializable is no longer
  over-reported, because the container knows what it synthesized
- a variance-compatible closing of a variant generic interface is now reported,
  because the probe runs the same matching the fallback does
- the relationship shapes (`Func<T>`, `Lazy<T>`, `Owned<T>`) are now reported.
  They were suppressed to mirror what MS.DI answers for shapes it does not have,
  but nothing reads a false as meaningful: a host told true takes the value from
  the provider, which here succeeds, so reporting them is strictly better

A root-withheld disposable transient is still reported, deliberately: `IsService`
answers whether the service exists, which is MS.DI's semantics, and minimal APIs
consult the probe on the root at endpoint-build time, so answering false there
would misbind every affected parameter. What changes is the other half. Resolving
it on the root surfaced `null`; it now surfaces the container's guidance naming the
fix, as MS.DI does for a scoping violation, so the failure lands at the cause.

`VerifyAgainst` no longer treats a probe's negative as final. It preferred the
probe and returned its answer, so an under-reported but resolvable shape became a
startup failure instead of falling through to the resolution check. A positive is
authoritative; a negative falls through, which costs nothing new.

Two divergences from MS.DI remain, both pinned: an open-generic closing nothing in
the graph asks for, and `IEnumerable<T>` of an unmentioned element type. In both
the probe is faithful to this container, which has no resolution either, so
matching MS.DI would mean building a collection for a type unknown at compile time.
GetKeyedService gains the same fallback GetService already has: a keyed
service the probe reports but TryResolve declines (a root-withheld
disposable transient under a key) now throws the container's guidance
instead of returning a silent null the probe's answer contradicts.

The variance matching loop moves into a shared __FindVariantMatch, used
by the fallback, the resolvability probe (dropping its duplicated
loop), and a new step in Resolve's miss path: a variant closing whose
nearest candidate is withheld on the Root now surfaces that candidate's
guidance rather than claiming no registration exists for a closing the
container serves from a child scope.

Also merges the accidentally doubled <summary> on
EmitKeyedResolutionApi. Both behavior fixes are pinned in
FeatureDetectionTests.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit b51e4c0 into main Jul 31, 2026
14 checks passed
@vbreuss
vbreuss deleted the feat/service-provider-is-service branch July 31, 2026 17:24
github-actions Bot added a commit that referenced this pull request Jul 31, 2026
…` from the container metadata (#133) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jul 31, 2026
…` from the container metadata (#133) 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