Skip to content

[AOT] Eliminate CloseAndBuildAs<T>(runtimeType) calls — design AOT-compliant replacement #2769

Description

@jeremydmiller

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:

  1. At Wolverine bootstrap, every known closed generic registers its factory: cache.Add<MyMessage>((args) => new MessageRouter<MyMessage>(args)).
  2. 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

  1. 6.0: leave the leaf suppressions from chunks D/I/J/K/O. Already shipped.
  2. 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.
  3. 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.
  4. 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

  • Each CloseAndBuildAs site listed in the table has a documented disposition (refactored / cached / kept-with-suppression-and-rationale).
  • At least the per-message hot paths are AOT-clean under TypeLoadMode.Static without leaf suppression.
  • Wolverine.AotSmoke covers the post-refactor surface as a CI regression guard (already 0 IL warnings, would extend with new types).
  • AOT publishing guide (docs/guide/aot.md) updated with the new factory-registration story.

Cross-links

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions