Skip to content

feat: allow lifecycle hooks to take graph-resolved parameters#110

Merged
vbreuss merged 6 commits into
mainfrom
feat/lifecycle-hook-dependencies
Jul 16, 2026
Merged

feat: allow lifecycle hooks to take graph-resolved parameters#110
vbreuss merged 6 commits into
mainfrom
feat/lifecycle-hook-dependencies

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 16, 2026

Copy link
Copy Markdown
Member

OnActivated/OnRelease hooks may now declare parameters after the leading instance parameter; each is resolved from the container graph exactly like a constructor or factory parameter. Activation dependencies are passed inline at the call; release dependencies are resolved at construction and captured by value into the queued closure, so reverse creation-order teardown keeps them alive until the release has run.

Hook parameters are full graph dependencies: AWT101 (missing), cycle, captive-dependency and async-taint analysis all account for them. A runtime [Arg] hook parameter is rejected with the new AWT189.

OnActivated/OnRelease hooks may now declare parameters after the leading
instance parameter; each is resolved from the container graph exactly like
a constructor or factory parameter. Activation dependencies are passed
inline at the call; release dependencies are resolved at construction and
captured by value into the queued closure, so reverse creation-order
teardown keeps them alive until the release has run.

Hook parameters are full graph dependencies: AWT101 (missing), cycle,
captive-dependency and async-taint analysis all account for them. A
runtime [Arg] hook parameter is rejected with the new AWT189.
@vbreuss vbreuss self-assigned this Jul 16, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   8m 0s ⏱️ +15s
1 114 tests + 31  1 113 ✅ + 31  1 💤 ±0  0 ❌ ±0 
5 432 runs  +125  5 431 ✅ +125  1 💤 ±0  0 ❌ ±0 

Results for commit b50946f. ± Comparison against base commit 7266d2d.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.77GHz, 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.051 ns 0.0174 ns 0.0136 ns 1.01 - NA
Awaiten 8 6.999 ns 0.0301 ns 0.0267 ns 1.00 - NA
MsDI 8 7.366 ns 0.0059 ns 0.0046 ns 1.05 - NA
Autofac 8 112.125 ns 3.1730 ns 2.9680 ns 16.02 656 B NA
Jab 8 2.825 ns 0.0026 ns 0.0022 ns 0.40 - NA
PureDI 8 6.380 ns 0.0278 ns 0.0246 ns 0.91 - NA
DryIoc 8 8.765 ns 0.0115 ns 0.0096 ns 1.25 - NA
SimpleInjector 8 10.909 ns 0.0281 ns 0.0249 ns 1.56 - NA
baseline* 256 8.397 ns 0.0082 ns 0.0068 ns 1.03 - NA
Awaiten 256 8.130 ns 0.0161 ns 0.0143 ns 1.00 - NA
MsDI 256 7.326 ns 0.0312 ns 0.0260 ns 0.90 - NA
Autofac 256 110.433 ns 5.1535 ns 4.8206 ns 13.58 656 B NA
Jab 256 42.701 ns 0.0713 ns 0.0595 ns 5.25 - NA
PureDI 256 7.328 ns 0.0082 ns 0.0068 ns 0.90 - NA
DryIoc 256 8.720 ns 0.0140 ns 0.0109 ns 1.07 - NA
SimpleInjector 256 14.620 ns 0.0430 ns 0.0402 ns 1.80 - 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* 232.6 ns 1.85 ns 1.73 ns 0.90 568 B 1.00
Awaiten 258.3 ns 3.47 ns 3.24 ns 1.00 568 B 1.00
MsDI 610.4 ns 10.16 ns 9.01 ns 2.36 1104 B 1.94
Autofac 6,924.2 ns 68.22 ns 63.81 ns 26.81 10784 B 18.99
Jab 187.6 ns 2.70 ns 2.53 ns 0.73 432 B 0.76
DryIoc 420.9 ns 5.91 ns 5.53 ns 1.63 944 B 1.66
SimpleInjector 759.1 ns 3.24 ns 2.87 ns 2.94 1096 B 1.93
PureDI 201.0 ns 5.17 ns 4.83 ns 0.78 632 B 1.11
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
INTEL XEON PLATINUM 8573C 3.50GHz, 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-v4

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

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 16.990 ns 0.2412 ns 0.2138 ns 1.26 144 B 1.00
Awaiten 8 13.509 ns 0.1198 ns 0.1120 ns 1.00 144 B 1.00
MsDI 8 1,210.066 ns 37.7532 ns 35.3144 ns 89.58 5688 B 39.50
Autofac 8 21,122.393 ns 162.5557 ns 152.0547 ns 1,563.65 33090 B 229.79
Jab 8 7.500 ns 0.3676 ns 0.3439 ns 0.56 96 B 0.67
PureDI 8 13.719 ns 0.3458 ns 0.3065 ns 1.02 128 B 0.89
DryIoc 8 587.433 ns 6.8280 ns 6.0529 ns 43.49 1528 B 10.61
SimpleInjector 8 9,915.161 ns 142.2872 ns 126.1339 ns 734.00 24760 B 171.94
baseline* 256 79.738 ns 0.3159 ns 0.2800 ns 0.89 2128 B 1.00
Awaiten 256 89.685 ns 1.0811 ns 1.0112 ns 1.00 2128 B 1.00
MsDI 256 10,234.535 ns 128.1967 ns 107.0500 ns 114.13 61016 B 28.67
Autofac 256 501,398.034 ns 2,257.6425 ns 2,111.8002 ns 5,591.33 742326 B 348.84
Jab 256 80.461 ns 0.5436 ns 0.5085 ns 0.90 2080 B 0.98
PureDI 256 83.373 ns 0.4058 ns 0.3388 ns 0.93 2112 B 0.99
DryIoc 256 33,962.042 ns 285.7377 ns 267.2792 ns 378.73 79743 B 37.47
SimpleInjector 256 279,497.748 ns 6,992.6722 ns 6,540.9499 ns 3,116.81 571196 B 268.42

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

vbreuss added 5 commits July 16, 2026 11:19
… pass

The graph-resolved parameters of OnActivated/OnRelease hooks were classified as full graph dependencies but only registered as edges in BuildEdges, so every other pass that walks an instance's constructor parameters skipped them. That let a hook parameter escape validation and the emitted infrastructure it needs, producing generated code that does not compile with no Awaiten diagnostic to explain it: an [ImportService<T>] hook parameter emitted a __ResolveExternal call the container never declared (CS1061); a plain hook parameter over a parameterized target missed AWT115 (CS7036); an Owned<T> hook parameter over a requesting-type factory missed AWT186 (CS1503); a synchronous Func<T> hook parameter over an async-tainted target missed AWT119/120 (CS0103); and a collection hook parameter with an async-initialized member missed AWT122 (CS0117). An [ImportService<T>] consumed only by a hook parameter was also wrongly reported unconsumed (AWT176).

Add InstanceModel.HookParameters() (activation plus release parameters) and visit it wherever ConstructorParameters is walked: external-dependency advertisement and the __ResolveExternal/__AsyncArray emit gates, AWT115/118/119/120/122/186 analysis, the unconsumed-import check, the fresh-disposable transient walk, and the captive-dependency message naming. BuildEdges now uses the same helper instead of iterating the two arrays separately.

Cover each failing scenario with a hook-parameter test in its owning diagnostic file, and add runtime tests proving an external hook parameter both compiles and resolves through the external resolver and is advertised as an external dependency.
…ease-order test

A lifecycle hook is reached by simple name, so when a registration names an overloaded method whose overloads all accept the instance as their first parameter, the container's choice - and the graph dependencies the extra parameters resolve - was silently order-dependent. Collect every accessible static void method of that name accepting the instance and, when more than one qualifies, report AWT190 and emit no hook, mirroring how AWT112 handles an ambiguous factory. One match is used as before; zero remains AWT164.

Also make ReleaseHookDependency_OutlivesTheRelease_InReverseCreationOrder actually exercise the release capture. PooledBuffer previously took the Pool in its constructor, which forced Pool-first creation on its own and would pass even if the capture ordering were wrong. Drop that constructor dependency so the reverse-teardown order is produced solely by the release capture resolving (and first-constructing, and queuing the release of) the Pool before the buffer's own release is enqueued - verified against the generated code.
ClassifyHookParameters mirrored the constructor/factory classification pipeline but omitted its three key-misuse reporters, so a mistake on a hook parameter that is rejected on a constructor parameter went undiagnosed: a [FromKey] whose constant is of an unsupported key type (AWT170), a synthesized keyed collection requested under an unsupported key type (AWT159), and a [FromKey] on a synthesized keyed collection (AWT160).

Run all three from ClassifyHookParameters at the pipeline positions the constructor path uses - AWT170 straight after classification, AWT159/AWT160 after SuppressRegisteredCollectionSynthesis so an explicitly registered dictionary rewritten to Direct is not reported. AWT170 is reported after the [Arg] rejection rather than before it, so a hook parameter already rejected with AWT189 does not collect a second diagnostic. Cover each with a hook-parameter test.

Also stop HookParameters() allocating on every call. It is invoked from every dependency-walking pass, several of them per-instance loops on the analyzer's per-keystroke path, and EquatableArray.AsArray() is already zero-alloc for an empty array. Return a backing array directly when either hook contributes no parameters (the overwhelmingly common case) and build a new array only when both do. A cached field is deliberately not used: record equality compares all instance fields, so a lazily populated cache would make two equal models compare unequal and break incremental-generation caching.

Document that [RequestingType] is factory-only, so like a constructor parameter it is not honored on a hook parameter: a hook is invoked by the container for an instance rather than requested by a consumer, so there is no requesting consumer whose typeof(...) it could receive.
A release hook's dependencies are captured at construction, but a Func<T>/Lazy<T> parameter captures only a resolver delegate, and the hook runs during its owner's teardown, after __disposed is set - so invoking the delegate there throws ObjectDisposedException every time, making the parameter unusable at the only moment the hook runs. Report AWT191 (error) for the four deferred-delegate kinds (Func, Lazy, FuncTask, LazyTask, covering their Task and Owned forms) on OnRelease hook parameters. An OnActivated hook runs while the owner is alive, so its Func/Lazy parameters remain allowed. IAsyncEnumerable<T> needs no rejection: it materializes its members eagerly at capture time.

Unlike a rejected [Arg] (AWT189), the parameter is kept rather than dropped: it resolves like any graph dependency, so the emitted capture and every downstream dependency-walking pass stay coherent while the error fails the build.

The AWT118 analyzer test's fixture was a release hook holding a Func over a disposable transient - exactly the shape AWT191 now rejects, and the analyzer harness requires its source to compile. Move the scenario to an activation hook, which is the surviving hook-parameter shape that walk covers (a root-owned singleton's activation hook can invoke its Func, each call tracking a fresh disposable on the root), and update the analyzer comment that cited the release-capture rationale.

Also close the runtime coverage gaps on the hook-parameter emit paths: an async-initialized activation hook dependency is awaited (initialized) before the hook runs on the async caching path, an async release capture is awaited at construction on the async fresh path, and a scoped instance's release capture goes through the cached release-registration path and runs when its scope is disposed.
…endencies()

Routing lifecycle hook parameters through every dependency-walking pass added a third sibling foreach (constructor parameters, injected [Inject] members, hook parameters) to each of them, pushing DetectSynchronousAsyncResolution, DetectSynchronousAsyncCollection and ExternalDependencies over Sonar's cognitive-complexity threshold of 15 (16, 17 and 19).

Add InstanceModel.WalkedDependencies(), enumerating the three groups in the order the passes already visited them (constructor parameters, member dependencies, hook parameters), and collapse each pass to a single loop over it - so diagnostic order and ExternalDependencies' first-seen dedup order are unchanged. DetectOwnedOverRequestingTypeFactory was below the threshold but shares the identical shape, so it is converted too rather than left as the one hand-rolled copy of the pattern. Passes that treat the groups differently (deferred members in BuildEdges, the [Arg]-index-sensitive walks) keep enumerating them separately, as the helper's doc note spells out.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit f70bc2a into main Jul 16, 2026
14 checks passed
@vbreuss
vbreuss deleted the feat/lifecycle-hook-dependencies branch July 16, 2026 13:58
github-actions Bot added a commit that referenced this pull request Jul 16, 2026
github-actions Bot added a commit that referenced this pull request Jul 16, 2026
@github-actions

Copy link
Copy Markdown

This is addressed in release v0.6.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request state: released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant