Repository navigation
A handler returning a value type crashes a Native AOT image at startup: PrepopulateRoutingCache closes EmptyMessageRouter<Guid>, and no root can reach it #4848
Description
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:
HandlerChain.PublishedTypes()yields every variable a handler creates, so aGuidreturn is a "published type" andHandlerGraph.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 removedINotToBeRoutedreturn types from that list for the side-effect sibling of this bug. AGuidis not one of those: Wolverine will cascade it if the handler is not being invoked for a response, so it is legitimately routed.)HandlerRegistryCodeFileemitsMessageRouter<T>/EmptyMessageRouter<T>roots only for the chain message types, never for published types. Reference-type published types survive anyway, through canonical sharing plus atypeoftoken (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 forEmptyMessageRouter<Guid>.[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:
- De-genericize the routers.
MessageRouterBase<T>usesTfortypeof(T)and for typed overloads thatIMessageRouteralready exposes asobject. AType-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 atMessageRouter<T>'s topic and partition delegates before committing to it. - 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, andRoutingForconsults it before closing. Works, but it is a second direct-construction table next to_frameworkRouterFactoriesand it only covers what the registry saw atcodegen writetime. - Not a
[DynamicDependency]overEmptyMessageRouter<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
Activity
GH-4848: Bug_4710's RavenDb queries wait for non-stale results
GH-4848: de-genericize the message routers so a native image never cl…