From 0c51c16fbe86a190267969b982f85afba1c107d0 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 5 Oct 2026 18:37:37 -0500 Subject: [PATCH 1/3] GH-4825: construct the internal agent messages' routers directly for Native AOT PrepopulateRoutingCache walks every message type the handler graph discovered, and RoutingFor's cache-miss path closes MessageRouter / EmptyMessageRouter over each one reflectively. An application with a durable message store has the framework's own agent messages in that set -- and they are internal, so HandlerRegistryCodeFile.onlyPublic() drops them and the emitted [DynamicDependency] rooting block CANNOT name them: generated code cannot put a non-public type inside a typeof(). In a native image ILC has therefore trimmed exactly those instantiations, and a Balanced-mode durable app dies at startup. Direct construction in _frameworkRouterFactories is the only fix available, which is what GH-4287 already did for IAgentCommand itself. Reported and diagnosed by @chrisbbe in #4827, which is credited as co-author here. That PR overlapped #4828 almost entirely and conflicts with main in seven files after it merged, so this lands the one part main was still missing rather than asking for a wholesale rebase. #4840 and #4841 track the two remaining places where #4827's version is the better one. Held to COVERAGE of the real set rather than to a list, and that is not a formality: #4827 named eight types -- the ones its lane happened to reach -- and the coverage test immediately found SIX MORE with the identical defect (AssignAgent, AssignAgents, ReassignAgent, ReassignAgents, StopRemoteAgent, StopRemoteAgents), the Balanced-mode leader's own assignment messages built in NodeAgentController.EvaluateAssignments and AssignmentGrid. All fourteen are covered now, and adding a new agent message fails the test until its factory exists. The test carries its own vacuity guard: the scan must find something, and every type it finds must really be non-public, which is the reason none of them can be rooted from generated code. Gates: wolverine.slnx Release -f net9.0 0 errors / 0 warnings; CoreTests 3289 green. Co-Authored-By: chrisbbe Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj --- ...ter_factories_cover_every_agent_message.cs | 83 +++++++++++++++++++ .../Runtime/WolverineRuntime.Routing.cs | 31 ++++++- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/Testing/CoreTests/Runtime/framework_router_factories_cover_every_agent_message.cs diff --git a/src/Testing/CoreTests/Runtime/framework_router_factories_cover_every_agent_message.cs b/src/Testing/CoreTests/Runtime/framework_router_factories_cover_every_agent_message.cs new file mode 100644 index 0000000000..4e5ecd51ee --- /dev/null +++ b/src/Testing/CoreTests/Runtime/framework_router_factories_cover_every_agent_message.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using JasperFx.Core.Reflection; +using Shouldly; +using Wolverine.Runtime; +using Wolverine.Runtime.Agents; +using Xunit; + +namespace CoreTests.Runtime; + +/// +/// GH-4825. PrepopulateRoutingCache walks every message type the handler graph discovered, and +/// RoutingFor's cache-miss path closes MessageRouter<T> / EmptyMessageRouter<T> +/// over each one reflectively. An application with a durable message store has the framework's own agent +/// messages in that set — and they are internal, so +/// HandlerRegistryCodeFile.onlyPublic() drops them and the emitted [DynamicDependency] +/// rooting block cannot name them: generated code cannot put a non-public type inside a +/// typeof(). In a native image ILC has therefore trimmed exactly those instantiations. +/// +/// Direct construction in _frameworkRouterFactories is the only fix available, which is +/// what GH-4287 already did for itself. +/// +/// +/// Asserted as COVERAGE of the real set rather than as a list of the eight types that were missing. +/// A list would be satisfied the day it was written and say nothing when the next agent message is +/// added — and the cost of missing one is a native image that fails at startup, which no analyzer +/// reports and only a Balanced-mode publish reproduces. +/// +public class framework_router_factories_cover_every_agent_message +{ + [Fact] + public void every_internal_agent_message_has_a_directly_constructed_router_factory() + { + var factories = frameworkRouterFactoryKeys(); + + // Not vacuous: if the reflection above ever stops finding the field, this fails rather than + // passing over an empty set. + factories.ShouldNotBeEmpty(); + + var uncovered = agentMessageTypes() + .Where(x => !factories.Contains(x)) + .OrderBy(x => x.FullName, StringComparer.Ordinal) + .ToArray(); + + uncovered.ShouldBeEmpty( + $"These agent message types are internal, so no emitted rooting block can name them, and nothing constructs their routers directly -- a native image with a durable message store will fail in PrepopulateRoutingCache: {string.Join(", ", uncovered.Select(x => x.Name))}"); + } + + [Fact] + public void the_agent_message_scan_actually_finds_something() + { + // The guard for the test above. agentMessageTypes() returning nothing would make it green while + // covering no types at all, which is precisely the failure mode this whole class of AOT bug has. + agentMessageTypes().ShouldNotBeEmpty(); + + // And they really are non-public, which is the reason they cannot be rooted from generated code. + foreach (var type in agentMessageTypes()) + { + type.IsPublic.ShouldBeFalse($"{type.FullName} is public, so the rooting block could name it"); + } + } + + private static Type[] agentMessageTypes() + { + return typeof(IAgentCommand).Assembly + .GetTypes() + .Where(x => x is { IsAbstract: false, IsInterface: false, IsGenericTypeDefinition: false }) + .Where(x => x.CanBeCastTo() || x.CanBeCastTo()) + .Where(x => !x.IsPublic) + .ToArray(); + } + + private static HashSet frameworkRouterFactoryKeys() + { + var field = typeof(WolverineRuntime).GetField("_frameworkRouterFactories", + BindingFlags.NonPublic | BindingFlags.Static); + + field.ShouldNotBeNull("WolverineRuntime._frameworkRouterFactories was renamed; this test needs updating"); + + var dictionary = (System.Collections.IDictionary)field!.GetValue(null)!; + + return dictionary.Keys.Cast().ToHashSet(); + } +} diff --git a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs index 3940a42d9b..8cce8558f9 100644 --- a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs +++ b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs @@ -227,7 +227,36 @@ private static readonly Dictionary(), [typeof(Acknowledgement)] = routerFactory(), [typeof(FailureAcknowledgement)] = routerFactory(), - [typeof(IAgentCommand)] = routerFactory() + [typeof(IAgentCommand)] = routerFactory(), + + // GH-4825. The concrete agent messages the framework itself handles. They are internal, so the + // `codegen write` rooting block cannot name them -- HandlerRegistryCodeFile.onlyPublic() drops + // every non-public type, because generated code cannot put one inside a typeof(). An application + // with a durable message store walks all of them through PrepopulateRoutingCache at startup, so + // in a native image the reflective close in RoutingFor fires against instantiations nothing + // could root. Direct construction is the only fix available, exactly as GH-4287 did for + // IAgentCommand above. + // + // Held to COVERAGE of the real set by framework_router_factories_cover_every_agent_message, + // not to this list: the eight types the reported failure named were the ones that application's + // lane happened to reach, and that test immediately found six more with the identical defect -- + // the Balanced-mode leader's own assignment messages, built in + // NodeAgentController.EvaluateAssignments and AssignmentGrid. Add a new agent message and that + // test fails until it is covered here. + [typeof(AgentPresenceReport)] = routerFactory(), + [typeof(AgentsStarted)] = routerFactory(), + [typeof(AgentsStopped)] = routerFactory(), + [typeof(AssignAgent)] = routerFactory(), + [typeof(AssignAgents)] = routerFactory(), + [typeof(QueryAgentPresence)] = routerFactory(), + [typeof(ReassignAgent)] = routerFactory(), + [typeof(ReassignAgents)] = routerFactory(), + [typeof(StartAgent)] = routerFactory(), + [typeof(StartAgents)] = routerFactory(), + [typeof(StopAgent)] = routerFactory(), + [typeof(StopAgents)] = routerFactory(), + [typeof(StopRemoteAgent)] = routerFactory(), + [typeof(StopRemoteAgents)] = routerFactory() }; private static Func, IMessageRouter> routerFactory() From 0c6734aa42c4cd3462b7095de4013dd64c4c25d2 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 5 Oct 2026 18:51:58 -0500 Subject: [PATCH 2/3] GH-4825: correct the Marten lane's header, which went stale when #4828 merged Two lines from @chrisbbe's #4827 that main lost: - The header still said "This project carries NO hand-written roots, so the lane fails until the library itself is fixed." That was true when #4826 landed the lane red; the library was fixed in #4828 and the lane passes, so the sentence now reads as a standing excuse for a red lane. - It enumerated three startup failures. There were four. The fourth note also records what the lane does NOT cover, which is the more useful half: this lane runs Solo, where agent messaging is never used, so it cannot reproduce the failure the rest of this branch fixes. GH-4843 tracks that gap. Co-Authored-By: chrisbbe Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj --- .../Wolverine.AotSmoke.Marten.csproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj b/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj index c1f65f8587..74fc2e80e3 100644 --- a/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj +++ b/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj @@ -21,6 +21,9 @@ not the Configuration.Applier that the GH-4778 rooting block covers. 3. IntegrateWithWolverine registers Envelope with Schema.For(), and in a native image Marten's DocumentMapping cannot find Envelope.Id. + 4. With a durable message store, PrepopulateRoutingCache closes MessageRouter over the + framework's internal agent messages (StopAgents and its siblings), which nothing rooted. + NOT reproduced by this lane -- it runs Solo, where agent messaging is never used. See GH-4843. The endpoint shapes are the ones the reporting application uses. Boots, serves both endpoints and reads the started stream back. Needs the postgresql service from docker-compose.yml. @@ -29,7 +32,9 @@ dotnet run -f net9.0 codegen write - This project carries NO hand-written roots, so the lane fails until the library itself is fixed. + This project carries NO hand-written roots, so a regression in the library's own rooting fails the + lane outright. (It said "the lane fails until the library itself is fixed" while that was true; the + library was fixed in #4828 and the lane passes.) --> true false From 81951c9d9ddbcfc2d883f60fd5cbfb1b7dedacff Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 5 Oct 2026 18:53:12 -0500 Subject: [PATCH 3/3] Fix the XML comment I just broke: '--' is illegal inside an XML comment My previous commit put a literal "--" in the csproj's comment block, which makes MSBuild refuse to load the project at all (MSB4025). Every project referencing it fails, which is to say the whole solution. I pushed it because the verification command was `dotnet build ... | grep -E "error| Build succeeded" && git commit`, and grep SUCCEEDS when it finds the word "error" -- so the && ran on a failed build. Check the build's own exit code, not whether a grep matched something. Gates, properly this time: the project loads, and wolverine.slnx Release -f net9.0 is 0 errors / 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj --- .../Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj b/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj index 74fc2e80e3..e1fcd0847a 100644 --- a/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj +++ b/src/Testing/Wolverine.AotSmoke.Marten/Wolverine.AotSmoke.Marten.csproj @@ -23,7 +23,7 @@ Marten's DocumentMapping cannot find Envelope.Id. 4. With a durable message store, PrepopulateRoutingCache closes MessageRouter over the framework's internal agent messages (StopAgents and its siblings), which nothing rooted. - NOT reproduced by this lane -- it runs Solo, where agent messaging is never used. See GH-4843. + NOT reproduced by this lane, which runs Solo, where agent messaging is never used (GH-4843). The endpoint shapes are the ones the reporting application uses. Boots, serves both endpoints and reads the started stream back. Needs the postgresql service from docker-compose.yml.