Summary
Across the AOT pillar (#2746) chunks D / I / J / K / O we suppressed IL2026 + IL3050 at every Wolverine call site that uses JasperFx.Core.Reflection.TypeExtensions.CloseAndBuildAs<TInterface>(runtimeType, ...). The helper does MakeGenericType + Activator.CreateInstance under the hood — fundamentally not AOT-compliant when the closed generic isn't statically known.
This issue tracks designing an AOT-compliant replacement so the suppressions can be removed.
Current CloseAndBuildAs call sites in Wolverine
| Site |
Closed generic |
Suppressed in |
IntrinsicSerializer.Write / ReadFromData |
IntrinsicSerializer<T> |
#2756 (chunk D) |
LocalTransport.ApplyConfiguration |
Applier<T> |
#2762 (chunk I) |
WolverineRuntime.RoutingFor |
MessageRouter<T> / EmptyMessageRouter<T> |
#2763 (chunk J) |
MessageRoute ctor |
IntrinsicSerializer<T> |
#2763 (chunk J) |
PropertyNameGroupingRule.TryBuildGrouper |
Grouper<TConcrete, TProperty> |
#2764 (chunk K) |
MessagePartitioningRules.AddMessageType |
Grouper<TConcrete, TProperty> |
#2764 (chunk K) |
BatchingOptions ctor + BuildHandler |
DefaultMessageBatcher<T> / ProcessorBuilder<T> |
#2768 (chunk O) |
HandlerGraph.cs (528, 445) |
various — not yet annotated |
TBD |
Each follows the same shape: a generic helper class is closed over a runtime-resolved message/handler type, an instance is built, and a non-generic interface method is invoked.
Why straight CloseAndBuildAs cannot be made AOT-compliant
MakeGenericType requires the JIT to construct generic instantiations at runtime. Native AOT publishes a closed set of instantiations and cannot add new ones — unless the trimmer/AOT publisher already saw the closed generic statically, the call throws at runtime.
Activator.CreateInstance requires the constructor to survive trimming. With trimming-only (no AOT), [DynamicallyAccessedMembers(PublicConstructors)] on the type parameter is enough — but with AOT the type instantiation itself has to exist.
The IL warning text confirms this: "Use GenericFactoryCache with a delegate factory for trim-friendly hot paths."
Realistic options (most → least disruptive)
Option A — Pre-populate caches at handler-graph compile time
The cheapest change: every CloseAndBuildAs site we annotated has an ImHashMap<Type, T> cache that gets populated on first access. At startup, after HandlerGraph.Compile, walk the known message-type set and pre-build the closures into the cache. Steady state hits the cache and the reflective miss path never fires.
- Pro: minimal API change, drops cold-miss latency too.
- Con: still uses
CloseAndBuildAs to populate the cache — IL warnings remain on the populator method, but they're scoped to HandlerGraph.Compile rather than every per-message dispatch path. Native AOT still fails unless the closed generics already exist (Option B/C still needed).
- Already proposed for
_typedEnumerableCascadeMethods (chunk L) and _messageTypeRouting (chunk J) in the chunk-O follow-up notes.
Option B — GenericFactoryCache with explicit registration
JasperFx.Core ships a GenericFactoryCache that takes a Func<...> factory keyed by type. Restructure each site so that:
- At Wolverine bootstrap, every known closed generic registers its factory:
cache.Add<MyMessage>((args) => new MessageRouter<MyMessage>(args)).
- The dispatch path looks up the factory by
typeof(T) and invokes it.
- Pro: factories are statically known closed generics, AOT-clean by construction.
- Con: requires enumerating message types at registration time (the same enumeration
_messageTypeRouting pre-population would need). Source-generated handler discovery (TypeLoadMode.Static) is the natural place to emit these registrations.
Option C — Source-generated factory class
A Wolverine source generator (likely in JasperFx.SourceGeneration since it already runs as an analyzer here) emits a <AppName>WolverineFactories partial class containing static factory methods for every closed-generic instantiation Wolverine needs. Bootstrap code calls it once to register all factories.
- Pro: zero runtime reflection. Native-AOT-clean.
- Con: source generator complexity. Couples Wolverine's per-site generic shapes to the generator. Discovery must be opt-in (TypeLoadMode.Static). Existing
Wolverine.AotSmoke (src/Testing/Wolverine.AotSmoke) gives us the regression-guard surface.
Option D — Per-site refactors to non-generic
Several sites use the type parameter only for typeof(T) and type-system discipline. These can be made non-generic with a Type field and explicit boxing:
-
MessageRouter<T> / EmptyMessageRouter<T> — message type used for logging/diagnostics
-
Grouper<TConcrete, TProperty> — used to invoke PropertyInfo.GetValue (could be Func<object, string> built at registration)
-
Applier<T> — visitor for IConfigureLocalQueue (could be a non-generic dispatcher)
-
Pro: simplest possible code, no reflection, no source generators.
-
Con: per-site judgment calls, some performance cost from boxing (MessageRouter is on the hot dispatch path).
Option E — Push annotations down into JasperFx.Core
Annotate CloseAndBuildAs itself with [RequiresDynamicCode] + [RequiresUnreferencedCode] so callers must explicitly acknowledge. Wolverine has effectively already done this via leaf suppression, but the annotation belongs on the helper.
- Pro: makes the AOT contract explicit at the helper.
- Con: doesn't actually fix anything at the call sites — just relocates the suppressions. Worth doing as a JasperFx.Core hygiene PR but not a substitute for A/B/C/D.
Proposed sequencing
- 6.0: leave the leaf suppressions from chunks D/I/J/K/O. Already shipped.
- 6.x point release: implement Option A for the per-message hot paths (
RoutingFor, ResolveTypedAsyncEnumerableCascader, IntrinsicSerializer). Drops cold-miss latency and scopes the IL warnings to startup.
- 6.x or 7.0: implement Option B + Option D in combination. Refactor the type-only-for-typeof sites to non-generic; for the rest, route through
GenericFactoryCache with registrations emitted by the existing static codegen pillar.
- 7.0 (stretch): if Option C is needed, build the source generator. Probably overkill unless someone hits a specific site that A+B+D can't handle.
Acceptance criteria
Cross-links
Summary
Across the AOT pillar (#2746) chunks D / I / J / K / O we suppressed IL2026 + IL3050 at every Wolverine call site that uses
JasperFx.Core.Reflection.TypeExtensions.CloseAndBuildAs<TInterface>(runtimeType, ...). The helper doesMakeGenericType+Activator.CreateInstanceunder the hood — fundamentally not AOT-compliant when the closed generic isn't statically known.This issue tracks designing an AOT-compliant replacement so the suppressions can be removed.
Current
CloseAndBuildAscall sites in WolverineIntrinsicSerializer.Write/ReadFromDataIntrinsicSerializer<T>LocalTransport.ApplyConfigurationApplier<T>WolverineRuntime.RoutingForMessageRouter<T>/EmptyMessageRouter<T>MessageRoutectorIntrinsicSerializer<T>PropertyNameGroupingRule.TryBuildGrouperGrouper<TConcrete, TProperty>MessagePartitioningRules.AddMessageTypeGrouper<TConcrete, TProperty>BatchingOptionsctor +BuildHandlerDefaultMessageBatcher<T>/ProcessorBuilder<T>HandlerGraph.cs(528, 445)Each follows the same shape: a generic helper class is closed over a runtime-resolved message/handler type, an instance is built, and a non-generic interface method is invoked.
Why straight
CloseAndBuildAscannot be made AOT-compliantMakeGenericTyperequires the JIT to construct generic instantiations at runtime. Native AOT publishes a closed set of instantiations and cannot add new ones — unless the trimmer/AOT publisher already saw the closed generic statically, the call throws at runtime.Activator.CreateInstancerequires the constructor to survive trimming. With trimming-only (no AOT),[DynamicallyAccessedMembers(PublicConstructors)]on the type parameter is enough — but with AOT the type instantiation itself has to exist.The IL warning text confirms this: "Use
GenericFactoryCachewith a delegate factory for trim-friendly hot paths."Realistic options (most → least disruptive)
Option A — Pre-populate caches at handler-graph compile time
The cheapest change: every
CloseAndBuildAssite we annotated has anImHashMap<Type, T>cache that gets populated on first access. At startup, afterHandlerGraph.Compile, walk the known message-type set and pre-build the closures into the cache. Steady state hits the cache and the reflective miss path never fires.CloseAndBuildAsto populate the cache — IL warnings remain on the populator method, but they're scoped toHandlerGraph.Compilerather than every per-message dispatch path. Native AOT still fails unless the closed generics already exist (Option B/C still needed)._typedEnumerableCascadeMethods(chunk L) and_messageTypeRouting(chunk J) in the chunk-O follow-up notes.Option B —
GenericFactoryCachewith explicit registrationJasperFx.Core ships a
GenericFactoryCachethat takes aFunc<...>factory keyed by type. Restructure each site so that:cache.Add<MyMessage>((args) => new MessageRouter<MyMessage>(args)).typeof(T)and invokes it._messageTypeRoutingpre-population would need). Source-generated handler discovery (TypeLoadMode.Static) is the natural place to emit these registrations.Option C — Source-generated factory class
A Wolverine source generator (likely in
JasperFx.SourceGenerationsince it already runs as an analyzer here) emits a<AppName>WolverineFactoriespartial class containing static factory methods for every closed-generic instantiation Wolverine needs. Bootstrap code calls it once to register all factories.Wolverine.AotSmoke(src/Testing/Wolverine.AotSmoke) gives us the regression-guard surface.Option D — Per-site refactors to non-generic
Several sites use the type parameter only for
typeof(T)and type-system discipline. These can be made non-generic with aTypefield and explicit boxing:MessageRouter<T>/EmptyMessageRouter<T>— message type used for logging/diagnosticsGrouper<TConcrete, TProperty>— used to invokePropertyInfo.GetValue(could beFunc<object, string>built at registration)Applier<T>— visitor forIConfigureLocalQueue(could be a non-generic dispatcher)Pro: simplest possible code, no reflection, no source generators.
Con: per-site judgment calls, some performance cost from boxing (
MessageRouteris on the hot dispatch path).Option E — Push annotations down into JasperFx.Core
Annotate
CloseAndBuildAsitself with[RequiresDynamicCode]+[RequiresUnreferencedCode]so callers must explicitly acknowledge. Wolverine has effectively already done this via leaf suppression, but the annotation belongs on the helper.Proposed sequencing
RoutingFor,ResolveTypedAsyncEnumerableCascader,IntrinsicSerializer). Drops cold-miss latency and scopes the IL warnings to startup.GenericFactoryCachewith registrations emitted by the existing static codegen pillar.Acceptance criteria
CloseAndBuildAssite listed in the table has a documented disposition (refactored / cached / kept-with-suppression-and-rationale).TypeLoadMode.Staticwithout leaf suppression.Wolverine.AotSmokecovers the post-refactor surface as a CI regression guard (already 0 IL warnings, would extend with new types).docs/guide/aot.md) updated with the new factory-registration story.Cross-links