Skip to content

AOT (7/7): canary in CI with a warning baseline, buildTransitive .targets for automatic AOT/trim opt-in, Native AOT docs - #8608

Merged
Aaronontheweb merged 4 commits into
aot/io-dns-and-buffer-pool-not-configurablefrom
aot/m1-f-canary-ci-and-targets
Sep 25, 2026
Merged

Aaronontheweb merged 4 commits into
aot/io-dns-and-buffer-pool-not-configurablefrom
aot/m1-f-canary-ci-and-targets

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Changes

Stack: PR 7 of 7 for AOT milestone 1. Base is PR #8606; this PR shows only its own three commits. Do not merge this ahead of the rest of the stack: its CI job requires the canary to be green, which is only true with #8601–#8606 applied.

  1. AOT canary in CI (build-system/pr-validation.yaml, Linux only). Two publishes of src/aot/Akka.AOT.App: an unrooted one whose binary is run and must exit 0 and print [canary] OK (the real-app boot proof: rooting would mask a trimmed-away type), and a rooted one (TrimmerRootAssembly=Akka) whose IL2xxx/IL3xxx warnings under src/core/Akka/ are diffed against a checked-in baseline (src/aot/Akka.AOT.App/aot-warnings.baseline.txt, 12 entries: the four on the boot path plus DynamicAccess, Props.TypeName/PropsSurrogate, IpExtensions, and four in NewtonSoftJsonSerializer.cs). New warnings fail the job with a diff; removed ones pass with a notice naming the lines to delete. The checker is a .NET 10 file-based app, scripts/CheckAotWarnings.cs, keyed on code|file|member|normalized message so line drift doesn't churn the baseline; it fails rather than passes when it parses nothing (warm obj/, wrong repo root, PDB-less log), and the job self-tests it against a committed fixture with one extra warning. Publish and run logs are uploaded as artifacts.
  2. buildTransitive/Akka.targets packed with the Akka package: when a consumer sets PublishAot or PublishTrimmed, a target running before PrepareForILLink and the runtimeconfig input cache adds <RuntimeHostConfigurationOption Include="Akka.DynamicTypeLoading" Value="false" Trim="true" /> unless the app already declares that option. Proven against the packed nupkg in five consumer variants: PublishAot; PublishTrimmed; app opts out in its csproj; app opts out in Directory.Build.targets (the ordering case an evaluation-time item gets wrong); PublishAot from a .pubxml. The opt-in is therefore automatic, and the app's own value wins wherever it's declared.
  3. docs/articles/deployment/native-aot.md: what works, how the opt-in happens, what the switch-off behavior is, and what isn't supported yet (classic DotNetty remoting; custom mailboxes/loggers/extensions until the Setup APIs land; Newtonsoft off under AOT so message types need registered serializers).

scripts/test-aot-compatibility.ps1 and build-system/azure.aot.template.yaml from #7458 never landed on dev, so there is nothing to retire.

Ledger: the .targets is a behavior change for any trimmed or AOT publish that hand-rooted a HOCON-named type and relied on Type.GetType finding it; escape hatch is declaring the option with Value="true".

Design and measurements: epic #7246.

Checklist

@Aaronontheweb Aaronontheweb added the AOT Ahead-of-Time (AOT) Compilation label Sep 23, 2026
@Aaronontheweb Aaronontheweb added this to the 1.6.0 milestone Sep 23, 2026
@Aaronontheweb
Aaronontheweb deleted the aot/m1-f-canary-ci-and-targets branch September 23, 2026 02:24
@Aaronontheweb
Aaronontheweb restored the aot/m1-f-canary-ci-and-targets branch September 23, 2026 02:42
@Aaronontheweb Aaronontheweb reopened this Sep 23, 2026
Aaronontheweb added a commit that referenced this pull request Sep 23, 2026
… branches

Azure's pr trigger filters on the TARGET branch. The AOT milestone-1 stack (#8601-#8608) bases each PR on the previous branch under aot/, so without this entry every stacked PR shows 'PR Validation: skipping' and only the bottom PR is validated.
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from 8881230 to 566bc29 Compare September 23, 2026 02:46
@Aaronontheweb
Aaronontheweb changed the base branch from feature/aot-io-dns-and-buffer-pool-not-configurable to aot/io-dns-and-buffer-pool-not-configurable September 23, 2026 02:46
@Aaronontheweb
Aaronontheweb added this pull request to stack #8607 September 23, 2026 02:57
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from 8a80090 to efa85fa Compare September 23, 2026 14:19
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from efa85fa to 0c4552f Compare September 23, 2026 14:47
Aaronontheweb added a commit that referenced this pull request Sep 23, 2026
…in tables for formatter/stdout logger/scheduler (#8601)

* Add the Akka.DynamicTypeLoading feature switch

Adds internal AkkaFeatures.IsDynamicTypeLoadingSupported behind the Akka.DynamicTypeLoading
feature switch, which defaults to on. An ordinary JIT application therefore behaves exactly
as it does today - core keeps resolving HOCON type names through Type.GetType.

A trimmed or Native AOT application turns it off in its project file:

  <RuntimeHostConfigurationOption Include="Akka.DynamicTypeLoading" Value="false" Trim="true" />

With Trim="true" ILLink substitutes the property with the constant false, so the reflection
branches guarded by it become unreachable and are removed. Turning the switch off is also useful
on the JIT, where it acts as a strict mode: a HOCON type name that would not survive trimming
fails at startup instead of at publish time.

Both attribute families are needed and they do different jobs. [FeatureSwitchDefinition] is what
lets ILLink/ILC substitute the getter and delete the dead branches. [FeatureGuard] is what stops
the Roslyn trim/AOT analyzer reporting IL2026/IL3050 at the guarded call sites - measured with a
probe: with only the switch definition, a call site inside if (IsDynamicTypeLoadingSupported)
still warns IL2026.

The cost of [FeatureGuard] is IL4000: the analyzer only accepts a constant false or another
recognized check such as RuntimeFeature.IsDynamicCodeSupported, so it cannot verify a guard whose
value comes from AppContext and says so. It is a complaint about what the analyzer can prove, not
about whether the guard works - ILC does substitute the getter and does drop the branches, and the
publish carries no IL2026. Akka.dll does not enable EnableTrimAnalyzer/EnableAotAnalyzer, so
nothing reports IL4000 today; the trade-off is written up on the property for whoever turns them on.

AkkaFeatures.NotBuiltIn builds the ConfigurationException message every call site shares, so the
wording cannot drift as more sites are converted. The doc also records the conventions a call site
has to follow: two keys per type in a BuiltIn* table and StripAssemblyIdentity on the configured
value, never a third versioned key; and no Trim() of the value (Akka's HOCON parser already returns
every string trimmed) or of the keys (a trimmed key can collide with one already in the dictionary).

Brings forward the Akka.Util.TypeExtensions hunk the table lookups need: one internal
StripAssemblyIdentity that removes Version, Culture, PublicKeyToken, ProcessorArchitecture,
Retargetable and ContentType from a type name, which TypeQualifiedName now also calls. It replaces
a private RegexOptions.Compiled regex - compiled regexes emit IL at runtime, which Native AOT
cannot do, so under AOT they silently degraded to the interpreter - with a [GeneratedRegex].
Internal, so no public API change.

TypeQualifiedName() output is a wire manifest, so that rewrite was measured rather than eyeballed:
the new stripping was compared against the old regex over 5,544 real AssemblyQualifiedNames - every
type in Akka.dll, System.Private.CoreLib, System.Linq and Akka.Tests, plus hand-picked closed
generics, jagged arrays and nested generics - and the output was byte-identical in every case.

The property reads AppContext on every call rather than caching, so a spec can flip it at runtime;
every call site is actor system startup or a config reload, so nothing hot pays for it. On a
trimmed app the getter is already a constant and AppContext.SetSwitch has no effect on it.

Also fixes src/xunitSettings.props, which anchored xunit.runner.json on $(SolutionDir): that is
only defined for a solution build, so building a test project by its own path never copied the file
and the runner silently ran with parallel collections ON, which would let the switch specs here
race the rest of the suite. Anchored on $(MSBuildThisFileDirectory) instead - verified that
bin/.../xunit.runner.json now exists and the runner reports "parallel test collections = off"
(and "on [8 threads]" with the file removed).

No resolution call sites yet - the specs here cover the switch and the name normalization.

* Resolve the log formatter, stdout logger and scheduler from built-in tables

Three settings in core name a type that core itself ships: akka.stdout-logger-class and
akka.logger-formatter (read by Settings) and akka.scheduler.implementation (read by
ActorSystemImpl.ConfigureScheduler). All three went through Type.GetType, so the trimmer could
not tell which type was loaded and dropped it - the first unrooted AOT publish died in
Settings..ctor with "Could not load type of Akka.Event.SemanticLogMessageFormatter, Akka for
ILogMessageFormatter", and after that in ConfigureScheduler with "Could not resolve type
Akka.Actor.HashedWheelTimerScheduler in assembly Akka".

Each site now consults a BuiltIn* table first and constructs the type directly. A name that is
not in the table falls back to the old reflection code, moved into a private static method
marked [RequiresUnreferencedCode], and that fallback only runs while dynamic type loading is
on; with the switch off the site throws a ConfigurationException naming the setting, the value
and the switch, built by AkkaFeatures.NotBuiltIn so the three messages cannot drift apart.

Every table carries two spellings of each name it knows - the bare "Ns.T" (how akka.conf writes
the scheduler) and "Ns.T, Akka" (how akka.conf writes the log formatter) - and the lookup runs the
configured value through TypeExtensions.StripAssemblyIdentity first. That is what makes a full
AssemblyQualifiedName match, which matters because Akka.Hosting's LoggerConfigBuilder writes one
into HOCON: normalizing the value handles any version, where a third key spelled
typeof(T).AssemblyQualifiedName would only ever have matched the version of the build that emitted
it. A value that still misses the table has nowhere to go once the reflection branch is trimmed
away, which is why neither spelling may be removed.

The values are used exactly as Config.GetString returns them - no Trim() anywhere, because Akka's
HOCON parser already strips leading and trailing whitespace from every string it hands back, in
every form (quoted, unquoted, triple-quoted, concatenated, substituted), with a whitespace-only
value coming back empty or null. Verified rather than assumed.

Two behavior differences with the switch on, both recorded in BREAKING_CHANGES_V1.6.md: a built-in
type is now constructed directly, so a constructor that rejects its config reports its own exception
rather than the TargetInvocationException Activator.CreateInstance wrapped it in; and a built-in
name written with the assembly-qualified name of a different Akka version now resolves where
Type.GetType rejected the version mismatch - measured on dev as ArgumentException for the two
Settings sites and FileNotFoundException for the scheduler, whose Type.GetType passes
throwOnError: true. Custom names are unchanged: the reflection fallback still gets the raw value.

* Add the AOT canary app

src/aot/Akka.AOT.App is a Native AOT canary for core. It boots a local ActorSystem twice - once
from the bare default config, once through an empty BootstrapSetup, which is the path Akka.Hosting
and the DI integrations take - round-trips a message through one UntypedActor and one ReceiveActor
with Ask, checks that what core resolves from HOCON actually got built, then terminates under a
bounded 30s cap.

A silent boot is not proof of anything, because Serialization and Mailboxes log-and-continue when a
configured type name does not resolve: an ActorSystem will come up looking healthy with no
serializers registered at all. So exit 0 plus "[canary] OK" now requires two further things.

  1. No warnings. Each run installs a LogFilterSetup whose filter records every WARNING and ERROR
     the stdout logger is asked to print, and fails the run with the collected messages. That is
     what catches "The type name for serializer 'json' did not resolve to an actual Type",
     "Serialization binding to non existing serializer" and "Mailbox Requirement mapping [...] is
     not an actual type".
  2. Positive assertions. Serialization.FindSerializerFor returns a serializer for a string and for
     a byte[], Scheduler is a HashedWheelTimerScheduler, Settings.LogFormatter is a
     SemanticLogMessageFormatter, and Mailboxes.Lookup("akka.actor.default-mailbox") is an
     UnboundedMailbox.

AppDomain.UnhandledException prints the same failure block, so a crash on a pool thread cannot exit
quietly.

The project references only src/core/Akka and sets Akka.DynamicTypeLoading to false with
Trim="true", so ILLink removes core's reflection fallbacks and the app genuinely has to boot
without them. IlcTreatWarningsAsErrors is off because the repo-wide warnings-as-errors would
otherwise turn every IL2xxx/IL3xxx warning ILC emits into a failed publish, and printing that list
is the app's whole job; the app's own C# still builds with warnings as errors. PublishAot is gated
on a RuntimeIdentifier so a plain solution build does not restore the ILCompiler or emit a
self-contained output, and so that it never becomes a global MSBuild property that would switch the
trim/AOT analyzers on for Akka.csproj as a side effect. -p:RootAkka=true roots the Akka assembly so
ILC analyzes all of core rather than just the paths these two toy actors reach.

Registered in Akka.slnx under /AOT/, and deliberately not wired into CI yet: the AOT publish does
not reach "[canary] OK" until the remaining reflection sites are converted. Running the same
program on the JIT does, which is how the pass condition is checked for not being vacuous.

* CI: run PR validation for pull requests that target the stacked aot/* branches

Azure's pr trigger filters on the TARGET branch. The AOT milestone-1 stack (#8601-#8608) bases each PR on the previous branch under aot/, so without this entry every stacked PR shows 'PR Validation: skipping' and only the bottom PR is validated.

* Tighten StripAssemblyIdentity test and expose SwitchName for reuse

Should_strip_assembly_identity_From_a_qualified_type_name used
BeOneOf(bare, "Ns.T, Akka"), which let a wrong result pass for any
input. Switch to InlineData(input, expected) pairs and assert Be
on the exact expected value for each case.

Also widen SwitchName from private to internal so later specs in
other test projects/files within this collection can reference it
instead of redeclaring the switch name string themselves.
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch 2 times, most recently from af572c8 to dca22ef Compare September 23, 2026 21:13
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch 2 times, most recently from dcc0fa1 to 8746f23 Compare September 24, 2026 03:31
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch 2 times, most recently from 6c019fc to 4d312bb Compare September 24, 2026 15:26
@Aaronontheweb
Aaronontheweb removed this pull request from stack #8607 September 24, 2026 16:42
@Aaronontheweb
Aaronontheweb added this pull request to stack #8632 September 24, 2026 16:43
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from 4d312bb to 97b0f12 Compare September 24, 2026 16:44
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from 97b0f12 to b54ab53 Compare September 24, 2026 17:00
Adds an "AOT canary (Linux)" job to PR validation. It publishes
src/aot/Akka.AOT.App twice, because the two publishes prove different
things and neither is sufficient alone.

The UNROOTED publish is the real-application proof: ILC keeps only what the
entry point reaches, and the binary then has to boot two local ActorSystems
and print `[canary] OK`. It is the only step that can catch a type that got
trimmed away and is needed at runtime - rooting the assembly would keep that
type alive and mask exactly that bug. A silent exit 0 is not a pass either,
because core logs-and-continues when a configured type name will not
resolve, so the job greps for the string the canary's own watchdog and
assertions produce.

The ROOTED publish (-p:RootAkka=true) makes ILC analyse every path in the
library rather than the handful two toy actors reach, and its log is what
the baseline check reads. The unrooted publish sees 4 sites; the rooted one
sees 12. The 8 it adds - Props.TypeName, PropsSurrogate.FromSurrogate,
DynamicAccess.CreateInstanceFor, IpExtensions.GetInstanceField and four in
NewtonSoftJsonSerializer - are precisely where milestone 2 works, so gating
on the unrooted list alone would leave that work unwatched.

scripts/CheckAotWarnings.cs is a .NET 10 file-based app (no project to
maintain, runs on the SDK the job already installs). It keys each warning on
code + file + owning member + message, deliberately not on the line number,
so editing code above a warning site does not churn the baseline. Member and
message are both in the key because neither alone is enough: one member can
hold two warnings of the same code that differ only by the callee. It folds
the Roslyn analyzer's member-less form into the ILC/ILLink form of the same
warning, and normalizes away the whitespace the two tools disagree about.

Three ways the check could have measured nothing and still reported OK are
now hard failures: a warm obj/ that makes MSBuild skip ILC, a repo root that
pushes every path out of scope, and a PDB-less log where no warning carries
a file. Paths are made relative to an explicit --repo-root rather than cut
at the first "/src/" segment, which was wrong for any checkout living under
a directory called src.

A final step feeds the checker a committed three-line log holding one
warning that is not in the baseline and fails unless the checker rejects it,
so the red path is proven on every run. Both publish logs and the run log
are published as an artifact with condition: always().

Linux only, blocking from day one, and not routed through
azure-pipeline.template.yaml - that template opens with a full solution
build, and `dotnet publish` of the canary needs src/core/Akka and nothing
else.

Relates to #7246.
…f for trimmed and AOT publishes

Consumers of the Akka package should not have to know the feature switch
exists. The package now ships buildTransitive/Akka.targets, which adds
`RuntimeHostConfigurationOption Akka.DynamicTypeLoading = false` with
Trim="true" whenever the consuming project sets PublishAot or
PublishTrimmed. The file name has to match the package id or NuGet ignores
it.

buildTransitive/ is the only copy. The older build/ folder is for NuGet
clients before 5.0, and none of those can consume a net10.0 package in the
first place.

The item is added inside a target rather than at evaluation time, which
matters more than it looks. NuGet imports the package's .targets from
Microsoft.Common.targets, ahead of Directory.Build.targets, so an
evaluation-time guard cannot see an opt-out declared there and both items
end up in RuntimeHostConfigurationOption. That is worse than losing:
ILLink's _TrimmerFeatureSettings takes the item carrying Trim="true" (ours,
false) while runtimeconfig.json takes the last writer (theirs, true), so the
app reads the switch as on while the trimmer has already baked it off and
deleted the fallbacks - the hand-rooted type still throws and nothing says
why. Verified: the evaluation-time form produces two items, the target
produces one.

BeforeTargets names PrepareForILLink, which populates _TrimmerFeatureSettings
for ILLink and, via IlcCompileDependsOn, for ILC too; and
_GenerateRuntimeConfigurationFilesInputCache, which hashes the option items -
being before GenerateBuildRuntimeConfigurationFiles alone is not enough,
because that target depends on the cache one.

An app that declares the option itself wins, in either direction and from
anywhere MSBuild reads - csproj, Directory.Build.props,
Directory.Build.targets, a publish profile or -p. Ordinary untrimmed builds
add nothing at all.

Verified against the packed nupkg from apps outside the repo: PublishAot
with the switch untouched, PublishTrimmed with the switch untouched,
PublishAot with the app setting it to true in its csproj, PublishAot with
the app setting it to true in Directory.Build.targets, and PublishAot coming
from a .pubxml publish profile. The defaulted cases land
`"Akka.DynamicTypeLoading": false` in runtimeconfig.json and their unbound
types throw as designed; the opt-out cases land `true`, keep reflection and
resolve the Newtonsoft serializer.

Relates to #7246.
Adds docs/articles/deployment/native-aot.md, registered in the deployment
toc: what works under Native AOT and trimming today, how to opt in (nothing
to do if you reference the package, one csproj item otherwise), what the
Akka.DynamicTypeLoading switch changes when it is off, and what is still
missing - Akka.Remote and Akka.Cluster, custom mailboxes/loggers/extensions
pending the Setup APIs, and Newtonsoft being trimmed away.

Relates to #7246.
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-f-canary-ci-and-targets branch from b54ab53 to f648c13 Compare September 25, 2026 00:46

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - mostly docs and AOT packaging for nuget distribution

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Aaronontheweb
Aaronontheweb merged commit be04d6b into dev Sep 25, 2026
17 checks passed
@Aaronontheweb
Aaronontheweb deleted the aot/m1-f-canary-ci-and-targets branch September 25, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AOT Ahead-of-Time (AOT) Compilation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant