Repository navigation
Run the JasperFx command line from a Native AOT binary; gate it in CI (#1141) - #1142
Merged
Merged
Conversation
|
Another follow-up maybe which I've noted at the end of #1132: The |
This was referenced Nov 8, 2026
…#1141) An app ending in RunJasperFxCommands could not start as a Native AOT binary: every command failed, the default `run` included, while the same app worked under `dotnet run`. - JasperFxCommand<T> / JasperFxAsyncCommand<T> pass typeof(T) to UsageGraph instead of walking the command type's interfaces, whose metadata ILC drops (the ArgumentNullException from `help`). - JasperFx.SourceGenerator emits a [ModuleInitializer] beside each DiscoveredCommands manifest that registers the command types, `new T()` factories for commands and inputs, and [InjectService] injectors with the new GeneratedCommandRegistry. ActivatorCommandCreator, CommandFactory and the host wrapper use it before falling back to Activator.CreateInstance, the by-name manifest lookup and Type.GetProperties(), none of which work in a Native AOT image (the misleading "constructor injection" error from `run`, missing commands, and silently skipped [InjectService] properties). - JasperFx.Events now runs JasperFx.SourceGenerator, so its commands exist in an AOT binary. - describe's referenced-assemblies part falls back to the loaded assemblies where GetReferencedAssemblies() is not supported. - The RequiresUnreferencedCode / RequiresDynamicCode messages on CommandLineHostingExtensions say when an AOT app can suppress them. - New src/JasperFx.AotCliSmoke and `./build.sh smoke-test-aot-cli`, run on every PR from a new aot-cli job in dotnet.yml: publishes with PublishAot and runs help, help <command>, an app command, check-env, describe and run from the native binary. - docs/codegen/aot.md: correct package name, what the generator registers, a complete AOT app that keeps the command line, and PublishAot scoped to the publishing configuration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spectre.Console detects GitHub Actions and styles even redirected output, so 'help run' rendered correctly in CI but the plain-text Contains missed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeremydmiller
force-pushed
the
fix/1141-aot-cli-commands
branch
from
November 8, 2026 16:50
83c0209 to
f66ce5f
Compare
This was referenced Nov 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1141.
The bug
An app that ends in
RunJasperFxCommandscould not start as a Native AOT binary. Every command failed, includingrun, which is the default, while the same app worked underdotnet run. Reproduced with the issue's exact csproj andProgram.cson 2.100.1:helpthrewArgumentNullExceptionfromUsageGraph..ctor.JasperFxCommand<T>derived the input type by walkingGetType()'s interfaces, and ILC drops that metadata.runfailed with "JasperFx does not yet support constructor injection…". The generatedDiscoveredCommandsmanifest names commands withtypeof(...). That keeps the type but not its constructor, soActivator.CreateInstancethrewMissingMethodException.Two more problems turned up while testing:
Assembly.GetType(string)doesn't work in an AOT image either.projections,event-queryand the other Events commands were simply absent.[InjectService]silently skipped. The properties were found withType.GetProperties(), so the command hit aNullReferenceException.The fix
typeof(T)to a newUsageGraph(Type commandType, Type inputType)constructor.JasperFx.SourceGeneratornow emits a[ModuleInitializer]next to eachDiscoveredCommandsmanifest. It registers the following with a newGeneratedCommandRegistry, all as ordinary code the trimmer keeps:new T()factories for commands and their inputs;[InjectService]injectors.ActivatorCommandCreator,CommandFactory.TryRegisterCommandsFromManifest, and the[InjectService]host wrapper use the registry first. They fall back to the existing reflective paths only for assemblies built without the generator. Under the JIT,CommandFactoryforces the module initializer withRuntimeHelpers.RunModuleConstructor, so both runtimes take the same path.ModuleInitializerAttributeandGeneratedCommandRegistry, so netstandard2.0 projects and older JasperFx references are unaffected.JasperFx.SourceGenerator.describe: its referenced-assemblies part falls back to the loaded assemblies whereGetReferencedAssemblies()throwsPlatformNotSupportedException(Native AOT, see AddJasperFx kills startup under Native AOT: HasReferenceToJasperFxTool calls GetReferencedAssemblies(), which throws PlatformNotSupportedException #742).RequiresUnreferencedCode/RequiresDynamicCodemessages onCommandLineHostingExtensionsnow say when an AOT app can suppress them. The attributes have to stay because they can't tell whether the generator ran, which is why the reporter still saw them.CI gate: the command line from a native binary
src/JasperFx.AotCliSmokeis a WebApplication shaped like the issue's app. It also has an application command with a positional argument, an enumerable flag and an[InjectService]property../build.sh smoke-test-aot-clipublishes it withPublishAotand runs the native binary throughhelp(asserting all 11 commands from the app, JasperFx and JasperFx.Events),help run,help projections,help greet,greet World --shout --tags a b,check-env,describe,run, and the no-argument default. It asserts exit codes and output.It runs on every PR as a new
aot-clijob indotnet.yml, which installs clang and zlib. It's a separate job so the ILC publish doesn't count against the main build's 20-minute timeout.Negative control: with the library changes stashed and only the smoke app and target in place, the gate fails on
help(exit 134, the issue's crash).The IL warnings are not errors in that project on purpose. Reaching the command line pulls in commands whose bodies still reflect: describe's
OptionsDescription, stream-query's aggregate-type lookup, and System.Linq.Dynamic.Core. ILC reports those whatever the gate runs.JasperFx.AotSmokeremains the analyzer-clean gate.Docs (
docs/codegen/aot.md)The issue's documentation complaints were fair:
JasperFx.SourceGeneration).The guide now:
[JasperFxAssembly(typeof(...))]extension types are still created withActivator.It also scopes
PublishAotto the Release configuration. Set unconditionally, the SDK writesIsDynamicCodeSupported=falseinto the runtimeconfig thatdotnet runuses too.GenerationRulesthen throws forTypeLoadMode.Dynamic, which breaks the guide's own Development = Dynamic setup. The runtimeconfig flag is confirmed; the throw follows from readingGenerationRules.DefaultLoaderFor.Testing
CommandDiscoveryGeneratorTests(the generated registration compiles; factories, input factories, injectors, and the cases that fall back);GeneratedCommandRegistryTestsin CommandLineTests (built-in and app commands registered, factories used byActivatorCommandCreator, the injector and its missing-service error,UsageGraph(Type, Type)matching the reflective constructor). The existingcan_use_injected_servicestest now runs through the generated injector.TenantBatchQueueTests.a_composite_member_runs_inside_its_parents_turn_without_queueing, which passed on 5 reruns; it's a daemon timing test unrelated to this change.Follow-ups not in this PR
ApplyJasperFxExtensions, once fromRunJasperFxCommands).codegen previewwith no code files prints[red]…[/]literally.OptionsDescriptionreflection has no metadata.🤖 Generated with Claude Code