Skip to content

feat: support lifecycle hooks on a [Module]'s self-compiled [Scan]#127

Merged
vbreuss merged 7 commits into
mainfrom
feat/module-scan-lifecycle-hooks
Jul 21, 2026
Merged

feat: support lifecycle hooks on a [Module]'s self-compiled [Scan]#127
vbreuss merged 7 commits into
mainfrom
feat/module-scan-lifecycle-hooks

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 20, 2026

Copy link
Copy Markdown
Member

A [Module] could self-compile its own [Scan], and a [Scan] could name OnActivated/OnRelease hooks, but the two did not meet: a hook named on a module scan was silently dropped. This wires them together while keeping the module's hook method and implementation internal.

Each module scan match now emits, next to its factory, a public static wrapper that casts the exposure interface to the hook's first-parameter type and calls the user hook. A generic hook is closed at module build from the match's marker form, so the wrapper stays non-generic and a consumer in another assembly never sees generics or internal types on the signature. The wrapper name is carried on the emitted [GeneratedScanRegistration] through new OnActivated/OnRelease properties; the consumer reads them and the existing hook pipeline binds the wrapper and resolves its post-instance dependencies from the consumer's graph.

Hooks are resolved and validated in the module's own build, reusing the container hook diagnostics (AWT164/189/190/191/198) so errors land at the library source. Because a hook's post-instance dependencies appear on the public wrapper signature and are resolved by the consumer, each must be externally accessible; a dependency that is not is reported as AWT203. The hook method itself and the instance parameter may stay internal, since the wrapper reaches them from inside the module. The same-compilation direct-expand path sets Origin to the module so ResolveHook binds the module's own internal hook directly.

Adds generator tests for wrapper emission and the module-build diagnostics, and cross-assembly plus same-compilation runtime tests exercising internal non-generic and generic hooks firing on activation and release.


A [Module] could self-compile its own [Scan], and a [Scan] could name OnActivated/OnRelease hooks, but the two did not meet: a hook named on a module scan was silently dropped. This wires them together while keeping the module's hook method and implementation internal.

Each module scan match now emits, next to its factory, a public static wrapper that casts the exposure interface to the hook's first-parameter type and calls the user hook. A generic hook is closed at module build from the match's marker form, so the wrapper stays non-generic and a consumer in another assembly never sees generics or internal types on the signature. The wrapper name is carried on the emitted [GeneratedScanRegistration] through new OnActivated/OnRelease properties; the consumer reads them and the existing hook pipeline binds the wrapper and resolves its post-instance dependencies from the consumer's graph.

Hooks are resolved and validated in the module's own build, reusing the container hook diagnostics (AWT164/189/190/191/198) so errors land at the library source. Because a hook's post-instance dependencies appear on the public wrapper signature and are resolved by the consumer, each must be externally accessible; a dependency that is not is reported as AWT203. The hook method itself and the instance parameter may stay internal, since the wrapper reaches them from inside the module. The same-compilation direct-expand path sets Origin to the module so ResolveHook binds the module's own internal hook directly.

Adds generator tests for wrapper emission and the module-build diagnostics, and cross-assembly plus same-compilation runtime tests exercising internal non-generic and generic hooks firing on activation and release.
@vbreuss vbreuss self-assigned this Jul 20, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 20, 2026
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Test Results

   18 files  ± 0     18 suites  ±0   9m 21s ⏱️ +41s
1 238 tests +28  1 237 ✅ +28  1 💤 ±0  0 ❌ ±0 
5 871 runs  +96  5 870 ✅ +96  1 💤 ±0  0 ❌ ±0 

Results for commit 942d51d. ± Comparison against base commit bc1a782.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 3.69GHz, 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 17.72 ns 0.559 ns 0.523 ns 1.33 144 B 1.00
Awaiten 8 13.315 ns 0.1622 ns 0.1355 ns 1.00 144 B 1.00
MsDI 8 1,180.402 ns 55.8081 ns 52.2029 ns 88.66 5688 B 39.50
Autofac 8 26,315.917 ns 319.4729 ns 283.2044 ns 1,976.53 33094 B 229.82
Jab 8 6.470 ns 0.2093 ns 0.1957 ns 0.49 96 B 0.67
PureDI 8 11.956 ns 0.1984 ns 0.1759 ns 0.90 128 B 0.89
DryIoc 8 578.408 ns 7.2468 ns 6.4241 ns 43.44 1528 B 10.61
SimpleInjector 8 8,703.213 ns 27.2036 ns 24.1153 ns 653.68 24761 B 171.95
baseline* 256 96.23 ns 0.971 ns 0.811 ns 1.54 2128 B 1.00
Awaiten 256 62.394 ns 0.2229 ns 0.1861 ns 1.00 2128 B 1.00
MsDI 256 10,478.000 ns 109.1693 ns 91.1613 ns 167.94 61016 B 28.67
Autofac 256 639,175.135 ns 4,344.4559 ns 4,063.8067 ns 10,244.32 720197 B 338.44
Jab 256 53.135 ns 0.3333 ns 0.2955 ns 0.85 2080 B 0.98
PureDI 256 58.088 ns 1.1800 ns 0.9854 ns 0.93 2112 B 0.99
DryIoc 256 36,317.005 ns 303.5315 ns 269.0728 ns 582.07 80546 B 37.85
SimpleInjector 256 264,464.172 ns 1,638.2295 ns 1,367.9958 ns 4,238.68 573041 B 269.29
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 3.24GHz, 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* 284.6 ns 3.10 ns 2.90 ns 1.05 568 B 1.00
Awaiten 270.1 ns 1.47 ns 1.37 ns 1.00 568 B 1.00
MsDI 637.2 ns 14.57 ns 13.63 ns 2.36 1104 B 1.94
Autofac 6,689.4 ns 40.20 ns 37.60 ns 24.77 10784 B 18.99
Jab 182.1 ns 1.90 ns 1.69 ns 0.67 432 B 0.76
DryIoc 383.4 ns 3.48 ns 3.26 ns 1.42 944 B 1.66
SimpleInjector 712.0 ns 4.95 ns 4.63 ns 2.64 1096 B 1.93
PureDI 176.3 ns 2.39 ns 2.24 ns 0.65 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

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 5.320 ns 0.0321 ns 0.0268 ns 0.71 - NA
Awaiten 8 7.465 ns 0.0160 ns 0.0125 ns 1.00 - NA
MsDI 8 7.348 ns 0.0104 ns 0.0092 ns 0.98 - NA
Autofac 8 126.184 ns 0.5310 ns 0.4967 ns 16.90 656 B NA
Jab 8 2.836 ns 0.0168 ns 0.0149 ns 0.38 - NA
PureDI 8 5.419 ns 0.0044 ns 0.0035 ns 0.73 - NA
DryIoc 8 8.755 ns 0.0140 ns 0.0110 ns 1.17 - NA
SimpleInjector 8 10.645 ns 0.0115 ns 0.0096 ns 1.43 - NA
baseline* 256 6.216 ns 0.0060 ns 0.0047 ns 0.77 - NA
Awaiten 256 8.124 ns 0.0092 ns 0.0076 ns 1.00 - NA
MsDI 256 7.328 ns 0.0402 ns 0.0314 ns 0.90 - NA
Autofac 256 126.351 ns 0.5824 ns 0.5162 ns 15.55 656 B NA
Jab 256 42.643 ns 0.0273 ns 0.0256 ns 5.25 - NA
PureDI 256 8.285 ns 0.0474 ns 0.0396 ns 1.02 - NA
DryIoc 256 8.982 ns 0.1605 ns 0.1340 ns 1.11 - NA
SimpleInjector 256 14.270 ns 0.0160 ns 0.0134 ns 1.76 - NA

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

vbreuss added 6 commits July 20, 2026 19:50
Review fixes for lifecycle hooks on a [Module]'s self-compiled [Scan]:

- Hook slots on ImplInfo now carry their own origin: a hook name is
  owner-relative, so a same-compilation module scan's hook filling a slot
  on an ImplInfo whose Origin an earlier (container) scan fixed no longer
  resolves against the wrong owner (spurious AWT164, or silently binding
  a same-named container method). The same name on a different origin is
  a different method and conflicts (AWT199, loser module-qualified in the
  message) instead of merging, which also stops a losing scan from
  polluting another origin's closed-marker set (spurious AWT198).
- A second module [Scan] overlapping the same match now merges its hooks
  per slot like two container scans (fill empty slot, restate unions
  marker forms and re-resolves, conflict is AWT199) instead of silently
  dropping them at the overlap dedup.
- AWT204 (new): a module hook dependency parameter marked [FromKey] or
  [Inject] is rejected - the wrapper's bare signature would silently
  drop the attribute cross-assembly while a same-compilation container
  honored it (the hook twin of the factory's AWT200).
- A hook with a by-ref (ref/in/out) parameter is no longer a usable
  module hook (AWT164) instead of generating uncompilable wrapper code.
- The wrapper's synthetic instance parameter is suffixed away from a
  mirrored user parameter literally named awaiten__instance (CS0100).
- RawRegistration/ModuleScanExpansion carry closed marker forms per hook
  slot, so differently-markered hooks merged from two scans re-resolve
  exactly as the module build decided.
- Sonar: ResolveModuleHook bundles (module, compilation, diagnostics)
  into a ModuleScanContext (8 -> 5 parameters); TryMirrorHookParameters
  drops its unused Compilation parameter.
The overlap slot merge re-resolves a hook a second scan names for an
unclaimed slot, so a hook that already failed at the first scan (AWT164/
AWT189/AWT191/AWT198/AWT203/AWT204) was diagnosed again at every
overlapping scan restating the same name. The module-scan context now
records failed attempts per (match, slot, name); a repeated attempt still
resolves (the merge needs the outcome) but reports into a discarded sink,
while a fresh failure - a new name, or a restated hook newly ambiguous
over widened markers - reports normally. Found by a follow-up review of
the previous commit.
…cans

A hook that failed to resolve used to leave (or wipe) its slot unclaimed, discarding the accumulated closed-marker union: a later overlapping [Scan] restating the same name then re-resolved over only its own markers and could emit a fully wired wrapper bound to a scan-order-dependent generic closing beside the already-reported AWT198, and a later scan naming a different method bypassed the AWT199 conflict check entirely. A failed slot now keeps its name and marker union with only the wrapper absent, so a restatement re-resolves over the true union, a different name still conflicts, and same-compilation wiring (gated on the wrapper) never binds a failed hook. Covered by regression tests for the two-scan reopen, the three-scan wipe-then-reopen, and the conflict-after-failure cases.

Review cleanups alongside: the AWT190 message no longer blames a container that cannot choose (it is also reported at module build time, where no container exists), with its XML doc, docs entry and release note aligned; the AWT199 XML doc and docs entry now state that hook names are owner-relative, matching the cross-origin conflict the coalescer already reports; the emitted wrapper XML summary says it casts to the hook instance parameter type, which is what the code does for generic and base-typed hooks alike; the AWT203/AWT204 docs snippets declare Roaster so they are self-contained, and the AWT204 entry drops its em-dash.

Test and lint housekeeping: the AwtNNN diagnostic tests move out of SelfCompiledModuleScanHookTests into the DiagnosticTests.AwtNNN partial-class files (new files for AWT203/AWT204), TryMirrorHookParameters delegates per-parameter validation to a helper to cut its cognitive complexity, and EmitModuleHook builds the synthetic instance name with a StringBuilder.
The wrapper-gating ternaries added for failed hook slots pushed Collect past the cognitive complexity limit, so the module-scan expansion loop moves into CollectSameCompilationModuleScans with its explanatory comment as the XML doc. No behavior change.
…res it

ResolveModuleHook recorded a failed (match, slot, name) attempt to suppress repeat diagnostics across overlapping scans, but never cleared it when a restatement over a widened marker union succeeded. A still-later scan turning that union ambiguous then failed into the discarded sink: the hook was silently unwired while the only reported diagnostic stayed the first scan's AWT164, which no longer described the slot. A successful re-resolution now removes the record, so the record tracks the latest outcome and a fresh failure reports normally.
…a helper

The four hook ternaries sat two loops deep in CollectSameCompilationModuleScans, each paying the nesting penalty and pushing the method's cognitive complexity to 17 (Sonar allows 15). The RawRegistration construction moves to an expression-bodied SameCompilationScanRegistration helper carrying the hook-wiring rationale as its summary, leaving the loop at complexity 5 and the helper at 4. No behavior change.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit 66905a0 into main Jul 21, 2026
14 checks passed
@vbreuss
vbreuss deleted the feat/module-scan-lifecycle-hooks branch July 21, 2026 08:58
github-actions Bot added a commit that referenced this pull request Jul 21, 2026
…dule]`'s self-compiled `[Scan]` (#127) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jul 21, 2026
…dule]`'s self-compiled `[Scan]` (#127) by Valentin Breuß
@github-actions

Copy link
Copy Markdown

This is addressed in release v0.7.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.

Feature request: activation hooks on [Scan]

1 participant