Skip to content

Run the JasperFx command line from a Native AOT binary; gate it in CI (#1141) - #1142

Merged
jeremydmiller merged 2 commits into
mainfrom
fix/1141-aot-cli-commands
Nov 8, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
fix/1141-aot-cli-commands

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #1141.

The bug

An app that ends in RunJasperFxCommands could not start as a Native AOT binary. Every command failed, including run, which is the default, while the same app worked under dotnet run. Reproduced with the issue's exact csproj and Program.cs on 2.100.1:

  • help threw ArgumentNullException from UsageGraph..ctor. JasperFxCommand<T> derived the input type by walking GetType()'s interfaces, and ILC drops that metadata.
  • run failed with "JasperFx does not yet support constructor injection…". The generated DiscoveredCommands manifest names commands with typeof(...). That keeps the type but not its constructor, so Activator.CreateInstance threw MissingMethodException.

Two more problems turned up while testing:

  • JasperFx.Events commands missing. JasperFx.Events didn't run the generator, and finding a manifest by name with Assembly.GetType(string) doesn't work in an AOT image either. projections, event-query and the other Events commands were simply absent.
  • [InjectService] silently skipped. The properties were found with Type.GetProperties(), so the command hit a NullReferenceException.

The fix

  • Input type: the command base classes pass typeof(T) to a new UsageGraph(Type commandType, Type inputType) constructor.
  • Registration without reflection: JasperFx.SourceGenerator now emits a [ModuleInitializer] next to each DiscoveredCommands manifest. It registers the following with a new GeneratedCommandRegistry, all as ordinary code the trimmer keeps:
    • command types;
    • new T() factories for commands and their inputs;
    • [InjectService] injectors.
  • Registry first, reflection as fallback: 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, CommandFactory forces the module initializer with RuntimeHelpers.RunModuleConstructor, so both runtimes take the same path.
  • Compile safety: the initializer is emitted only when the compilation has both ModuleInitializerAttribute and GeneratedCommandRegistry, so netstandard2.0 projects and older JasperFx references are unaffected.
  • JasperFx.Events now runs JasperFx.SourceGenerator.
  • describe: its referenced-assemblies part falls back to the loaded assemblies where GetReferencedAssemblies() throws PlatformNotSupportedException (Native AOT, see AddJasperFx kills startup under Native AOT: HasReferenceToJasperFxTool calls GetReferencedAssemblies(), which throws PlatformNotSupportedException #742).
  • Warning messages: the RequiresUnreferencedCode / RequiresDynamicCode messages on CommandLineHostingExtensions now 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.AotCliSmoke is 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-cli publishes it with PublishAot and runs the native binary through help (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-cli job in dotnet.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.AotSmoke remains the analyzer-clean gate.

Docs (docs/codegen/aot.md)

The issue's documentation complaints were fair:

  • The package name was wrong (JasperFx.SourceGeneration).
  • The guide said "the CLI is a dev-time tool" and also that the manifest makes command discovery fully AOT-clean.
  • There was no complete example.

The guide now:

  • explains what the generator registers;
  • adds a full AOT app that keeps the command line, with the call-site suppression;
  • lists the caveats: every command-contributing assembly needs the generator, and [JasperFxAssembly(typeof(...))] extension types are still created with Activator.

It also scopes PublishAot to the Release configuration. Set unconditionally, the SDK writes IsDynamicCodeSupported=false into the runtimeconfig that dotnet run uses too. GenerationRules then throws for TypeLoadMode.Dynamic, which breaks the guide's own Development = Dynamic setup. The runtimeconfig flag is confirmed; the throw follows from reading GenerationRules.DefaultLoaderFor.

Testing

  • New tests:
    • CommandDiscoveryGeneratorTests (the generated registration compiles; factories, input factories, injectors, and the cases that fall back);
    • GeneratedCommandRegistryTests in CommandLineTests (built-in and app commands registered, factories used by ActivatorCommandCreator, the injector and its missing-service error, UsageGraph(Type, Type) matching the reflective constructor). The existing can_use_injected_services test now runs through the generated injector.
  • Local runs (macOS arm64): compile, test-core, test-codegen, test-command-line, test-source-generators, smoke-test-commands, smoke-test-aot, smoke-test-aot-cli all pass.
  • test-events: passes on net9.0. One failure on net10.0 in 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.
  • Not run locally: test-event-store, test-aspire, test-microsoft-extensions-ai, and the new gate on Linux. CI covers those.

Follow-ups not in this PR

  • The "Searching '…' for commands" lines print twice (once from ApplyJasperFxExtensions, once from RunJasperFxCommands).
  • codegen preview with no code files prints [red]…[/] literally.
  • describe's "JasperFx Options" part prints only the type name in an AOT binary, because OptionsDescription reflection has no metadata.

🤖 Generated with Claude Code

@Juff-Ma

Juff-Ma commented Nov 8, 2026

Copy link
Copy Markdown

Another follow-up maybe which I've noted at the end of #1132:

The --config option is completely broken. I've found this out while trying to work around the --log-level problem described in the issue. It will not set complex config options, limiting it to pretty much no practical functionality.

jeremydmiller and others added 2 commits November 8, 2026 10:50
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Minimal Hosted AOT will not run

2 participants