Skip to content

A handler returning a value type crashes a Native AOT image at startup: PrepopulateRoutingCache closes EmptyMessageRouter<Guid>, and no root can reach it #4848

Closed

Description

@jeremydmiller

Found while researching #4810, by prediction first and one native publish second. Not a user report.

What happens

A message handler that returns a value type kills a PublishAot application at startup:

System.NotSupportedException: 'Wolverine.Runtime.Routing.EmptyMessageRouter`1[System.Guid]' is missing native code or metadata.
   at System.Reflection.Runtime.TypeInfos.RuntimeTypeInfo.MakeGenericType(Type[])
   at JasperFx.Core.Reflection.TypeExtensions.CloseAndBuildAs[T](Type, Object, Type[])
   at Wolverine.Runtime.WolverineRuntime.RoutingFor(Type)
   at Wolverine.Runtime.WolverineRuntime.PrepopulateRoutingCache(IEnumerable`1)
   at Wolverine.Runtime.WolverineRuntime.<StartAsync>d__165.MoveNext()

Reproduced in Wolverine.AotSmoke.Fisher (net9.0, Generating native code asserted, then reverted) by adding exactly this and nothing else:

public record AotProbeQuery(Guid Id);

public static class AotProbeStructHandler
{
    public static Guid Handle(AotProbeQuery query) => query.Id;
}

That is the ordinary request/reply shape — await bus.InvokeAsync<Guid>(new CreateOrder(...)) — in any durable-store application. PrepopulateRoutingCache runs in every mode except MediatorOnly and Serverless, so the host never starts.

Why

Three facts, each already written down somewhere, that nobody had put together:

  1. HandlerChain.PublishedTypes() yields every variable a handler creates, so a Guid return is a "published type" and HandlerGraph.AllMessageTypes() hands it to the routing warm-up. (GH-4840, GH-4841, GH-4843: sharpen the side-effect AOT roots, guard the HTTP rooting order, run a Balanced native lane #4847 removed INotToBeRouted return types from that list for the side-effect sibling of this bug. A Guid is not one of those: Wolverine will cascade it if the handler is not being invoked for a response, so it is legitimately routed.)
  2. HandlerRegistryCodeFile emits MessageRouter<T> / EmptyMessageRouter<T> roots only for the chain message types, never for published types. Reference-type published types survive anyway, through canonical sharing plus a typeof token (see the measurement on Investigate a static gate for the AOT reflective-generics class, instead of relying on publish-and-run smokes (follow-up to #4765, not for the next release) #4810). A value type cannot: there is no shared template for EmptyMessageRouter<Guid>.
  3. [DynamicDependency] over a value-type instantiation roots metadata, not code (Sagas do not work under Native AOT: [DynamicDependency] does not root a value-type instantiation, and SagaSchemaFor is a generic virtual method #4805, rule 1a). So adding the published types to the rooting block would not fix this one; it would emit a root that looks right and does nothing.

How much of a real graph this is

Measured on CoreTests' own handler graph on CoreCLR (a throwaway probe, not committed):

chains 386
types PrepopulateRoutingCache walks 450
with a router root in the registry 386
without 64
of those, value types 3 (int, Guid, one user struct)

Every one of those three is a startup crash in a native image today.

Fix shapes

In the order I would try them:

  1. De-genericize the routers. MessageRouterBase<T> uses T for typeof(T) and for typed overloads that IMessageRouter already exposes as object. A Type-carrying non-generic router removes the reflective close entirely, which is the audit's stated preference ("prefer de-genericizing over rooting wherever the generic buys only a typed delegate") and the only shape that covers value types without generated code. Needs a look at MessageRouter<T>'s topic and partition delegates before committing to it.
  2. A generated factory for value-type published types, the Sagas do not work under Native AOT: [DynamicDependency] does not root a value-type instantiation, and SagaSchemaFor is a generic virtual method #4805 rule-3 pattern: codegen emits new EmptyMessageRouter<Guid>(runtime) by name, and RoutingFor consults it before closing. Works, but it is a second direct-construction table next to _frameworkRouterFactories and it only covers what the registry saw at codegen write time.
  3. Not a [DynamicDependency] over EmptyMessageRouter<Guid>. Rule 1a; it would be a root that resolves and does not generate.

Whichever lands, the Fisher lane should keep a Guid-returning handler so this stays measured.

Related: #4810 (the research this fell out of), #4805 (rule 1a), #4847 (the INotToBeRouted sibling).

🤖 Generated with Claude Code

https://claude.ai/code/session_017DbTkhs3JxSMCWasTzPdRq

Activity

  1. added 2 commits that reference this issue on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions