Skip to content

AOT (4/7): core serializers and bindings from a built-in table; serialization-identifiers matched by name — canary goes green - #8604

Merged
Aaronontheweb merged 5 commits into
devfrom
aot/m1-d-serializer-defaults
Sep 25, 2026
Merged

Aaronontheweb merged 5 commits into
devfrom
aot/m1-d-serializer-defaults

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Changes

Part of the milestone-1 AOT stack. Stacked on
aot/m1-a-feature-switch-and-first-tables (plus B and C).

Two commits, both confined to the serialization subsystem.

1. Match serialization-identifiers entries by type name instead of resolving every key

SerializerIdentifierHelper.GetSerializerIdentifierFromConfig already holds the Type whose id it wants. It
used to build a dictionary by calling Type.GetType(key, throwOnError: true) on every key under
akka.actor.serialization-identifiers, then look the type up in it. Now it formats the type it has and compares
that against each key.

That drops the last Type.GetType on the serialization startup path, and it fixes a latent bug on the way:
because Serialization..ctor reads Serializer.Identifier for every serializer it registers, one key naming
a type the application cannot load — a serializer configured by a package that is present in HOCON but not
referenced — made ActorSystem.Create itself throw, no matter how well-formed every other key was. The spec for
this fails on dev at ActorSystem.Create, not at the assertion.

How a key is matched

Each key is trimmed, run through the new TypeExtensions.StripAssemblyIdentity (strips Version, Culture,
PublicKeyToken, ProcessorArchitecture, Retargetable, ContentType), then split at the comma that separates
the type name from the assembly name — the first comma at bracket depth zero, so a closed generic's own commas
don't count. The type name is compared Ordinal; the assembly name OrdinalIgnoreCase, which is how
Type.GetType compared assembly names.

Assembly-qualified keys are matched across the whole block first, and only then bare FullName keys, so a
bare key cannot shadow an exact assembly-qualified key further down the block. There is a spec for that ordering.

What still matches that matched on dev

  • "Ns.T" (bare), "Ns.T, Asm", and "Ns.T, Asm, Version=…, Culture=…, PublicKeyToken=…"
  • "Ns.T,Asm" — no space after the comma
  • "Ns.T, asm" — assembly name in the wrong case
  • "Ns.T, Asm, …, ProcessorArchitecture=MSIL, Retargetable=Yes"
  • "Ns.Outer+Nested, Asm" — nested types spell with + on both sides
  • a bare key naming a generic type, because FullName is stripped too

All of these are in the [Theory].

What no longer matches (the narrowing)

String comparison cannot follow assembly binding, so a key that named the assembly some other way that
Type.GetType nevertheless resolved now misses and that serializer's id lookup throws ArgumentException:

  • a type-forwarded name — "Akka.Serialization.X, SomeOldAssembly" where SomeOldAssembly forwards the type
    to Akka
  • an assembly name that only bound through a binding redirect or a publisher policy
  • a generic type argument inside [[…]] spelled with a partial or different assembly name

What newly matches (the widenings)

  1. The assembly identity is ignored. On dev, a key that named a strong-named assembly with the wrong
    Version/PublicKeyToken failed to bind and (given the throwOnError: true) blew up the whole block. Now it
    matches. This is the intended behavior — TypeQualifiedName(), which is what Akka.NET itself writes as a wire
    manifest, strips exactly these components.
  2. A bare FullName key matches a type of that name in any assembly. On dev, Type.GetType searched only
    Akka.dll and corlib for an unqualified name, so a bare key naming a type in a third assembly threw. The risk
    this creates is worth stating plainly: two serializers both called Foo.MySerializer, in different assemblies,
    would now both match a single bare "Foo.MySerializer" key and share its id — and a shared serializer id
    corrupts the wire. Use assembly-qualified keys.

Shared identity stripping

The lookup uses Akka.Util.TypeExtensions.StripAssemblyIdentity, the [GeneratedRegex] helper PR #8601 (A)
introduced for every built-in table; TypeQualifiedName calls the same helper, so wire manifests and
config matching agree on what "assembly identity" means. A's commit message carries the 5,544-name
byte-identical measurement that validates the rewrite against the old regex.

2. Register core's built-in serializers and bindings without reflection

Core's own akka.conf declares two serializers (bytes, json) and two bindings (System.Byte[],
System.Object). Both HOCON loops resolved those names through Type.GetType, so the trimmer could not tell
which types were loaded and dropped them: the unrooted AOT canary logged four "did not resolve" warnings at
startup and booted with no serializers and two dangling bindings.

Both loops now consult a BuiltIn* table first and construct the type directly, per the milestone's resolution
order: table → else if (AkkaFeatures.IsDynamicTypeLoadingSupported) the old reflection code, moved into a
private static method marked [RequiresUnreferencedCode] → else throw a ConfigurationException built by
AkkaFeatures.NotBuiltIn, naming the setting, the value and the switch.

Table keys

BuiltInSerializers carries the bare and the assembly-qualified spelling of each name. BuiltInSerializationBindings
carries the bare spelling plus mscorlib, System.Private.CoreLib, System.Runtime and netstandard — these
two are framework types, so there is no single assembly-qualified spelling, and all four resolve through
Type.GetType. Versioned spellings need no keys of their own because the lookup runs the configured name through
StripAssemblyIdentity first.

That is deliberately in preference to a third key built from typeof(T).AssemblyQualifiedName. Both were
published on the canary: with the typeof keys the binary was 5,370,888 bytes, without them 5,370,872, and
neither produced any NewtonSoftJsonSerializer.cs trim warning. So the reason is not AOT size — it is that
stripping matches every version of a name, where a typeof-built key only ever matches the build it came from.

Newtonsoft under the switch

With the switch on nothing changes: json and the System.Object binding are registered exactly as today.

With the switch off, core does not register json at all. NewtonSoftJsonSerializer is reflection-driven from
top to bottom, and leaving it out beats registering a serializer that throws the first time anything is
serialized. A type with no binding of its own then has no fallback serializer, and FindSerializerFor /
FindSerializerForType throw through the path allow-unregistered-types already had — with an added sentence
saying why, because SerializationException: Serializer not found for type Poco on a default config is not
something a user can act on:

Serializer not found for type SomePoco The default 'System.Object' -> json binding is not registered because the
[Akka.DynamicTypeLoading] feature switch is disabled; register a serializer for this type through a
SerializationSetup.

The System.Object binding is decided by the alias, not the switch

This is the fix for the one blocker the review caught. An earlier revision skipped the System.Object binding
whenever the switch was off — which silently broke the very migration the ledger recommends: a SerializationSetup
alias plus "System.Object" = mine in HOCON, where mine is registered (setups are added before the bindings
loop). The row was dropped before _serializersByName was ever consulted.

Now the bindings table is unconditional, and the serializers loop records in a local skippedAliases set any
alias whose built-in factory declined to produce a serializer. A binding whose target is missing warns as before
unless the target is in that set. So:

  • "System.Object" = json, switch off → dropped, no warning (json was deliberately skipped)
  • "System.Object" = mine with mine from a SerializationSetup, switch off → honored
  • "System.Object" = typo → still warns Serialization binding to non existing serializer: 'typo'

Both the honored case and the silence are specs, and both fail against the previous revision.

Other behavior notes

  • A built-in serializer is constructed directly, so a constructor that rejects its
    akka.actor.serialization-settings block surfaces its own exception instead of the TargetInvocationException
    Activator.CreateInstance wrapped it in.
  • ByteArraySerializer has only a (ExtendedActorSystem) constructor. On dev, an
    akka.actor.serialization-settings.bytes block pushed the Activator call down the two-argument branch and
    threw MissingMethodException; the table now constructs it directly and ignores the block. Kept, because
    booting beats crashing on a setting that never did anything, and ledgered.
  • serialization-bindings keys are trimmed. HOCON trims values but not keys, and a binding's type name is the
    key, so this is a widening: Type.GetType rejected a padded bare name, making a padded key warn-and-skip
    before. Two keys landing on the same Type is harmless — AddSerializationMap is last-write-wins, which it
    already was for the several distinct spellings that resolved to the same Type on their own.
  • No .Trim() on config values: HOCON's GetString already trims those in every syntax, so a trim there
    would be dead code.

Verification

  • dotnet build src/core/Akka/Akka.csproj -c Release -warnaserror → 0/0; dotnet build Akka.slnx -c Release → 0/0
  • Full Akka.Tests, Akka.Remote.Tests --filter ~Serialization, Akka.Persistence.Tests --filter ~Serializ
  • Akka.API.Tests passes unchanged — no public API change
  • dotnet format --verify-no-changes on every touched file: no new violations
  • AOT canary: publishes clean, and Serialization.cs / Serializer.cs now produce zero IL2xxx/IL3xxx
    warnings (were Serialization.cs:232, Serialization.cs:259, Serializer.cs:232). The four serializer/binding
    "did not resolve" startup warnings are gone.
  • With A+B+C+D the unrooted canary prints [canary] OK and exits 0 with zero startup warnings (asserted
    by the watchdog). AssertBuiltInsResolved now pins the switch-off contract: byte[] resolves; an unbound
    type (string) throws a SerializationException naming Akka.DynamicTypeLoading and SerializationSetup.
    Akka-own IL warnings on the canary publish: 21 → 9, all in PR E's sites or later.

Stack: PR 4 of 7 for AOT milestone 1. Base is PR #8603 (C); this PR shows only its own two commits. This is the PR at which the milestone-1 canary goes green. Design and measurements: epic #7246.

Checklist

@Aaronontheweb Aaronontheweb added the AOT Ahead-of-Time (AOT) Compilation label Sep 23, 2026
@Aaronontheweb
Aaronontheweb added this pull request to stack #8607 September 23, 2026 02:07
@Aaronontheweb Aaronontheweb added this to the 1.6.0 milestone Sep 23, 2026
@Aaronontheweb
Aaronontheweb deleted the aot/m1-d-serializer-defaults branch September 23, 2026 02:24
@Aaronontheweb
Aaronontheweb restored the aot/m1-d-serializer-defaults branch September 23, 2026 02:42
@Aaronontheweb Aaronontheweb reopened this Sep 23, 2026
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from a5e7aac to b6fc02f Compare September 23, 2026 02:46
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from b6fc02f to f916473 Compare September 23, 2026 14:19
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from f916473 to 28befed Compare September 23, 2026 14:47
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from 28befed to 7181ae6 Compare September 23, 2026 18:09
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from 7181ae6 to 9586b91 Compare September 23, 2026 21:13
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch 2 times, most recently from 413ee45 to 29e8965 Compare September 24, 2026 03:31
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from 29e8965 to 0d764f4 Compare September 24, 2026 13:58

@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.

Mostly looks ok, with some nitpicks

// System.Object binding that points at it, and a type with no binding of its own has no fallback. That
// is the designed behavior, not a gap - so assert the throw, and assert the message tells the user what
// to do about it.
var unbound = RequireThrows(label, () => system.Serialization.FindSerializerFor("hello"));

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 - the comment above is correct about both the design and the assertion.


/// <summary>
/// A serializer that needs no reflection, so it is a legitimate thing to register under AOT - which is
/// exactly the migration the ledger recommends for a switched-off application.

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 - good thing to test outside all of the source-generated serialization infrastructure in Akka.NET v1.6


serialization.FindSerializerForType(typeof(byte[])).Should().BeOfType<ByteArraySerializer>();

// "System.Object" = json, so an otherwise unbound type falls back to Newtonsoft.Json

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

/// serializer that throws the first time anything is serialized. An AOT application that wants JSON
/// registers a serializer of its own through a <see cref="SerializationSetup"/>.
/// </summary>
private static object CreateNewtonSoftJsonSerializer(ExtendedActorSystem system, Config config)

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.

these serializers have distinct base classes we can use - why not use that here instead of returning object ?

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.

Done in 6cce8a4. The built-in table and both factories now return Serializer, the shared base of V1 and V2 serializers.

// Whether a built-in binding survives is decided by the alias, not by the feature switch:
// core's own "System.Object" = json row lands here when json was skipped, which is expected,
// but the very same row pointed at a SerializationSetup alias must still be honored.
if (!skippedAliases.Contains(serializerName))

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

}

[RequiresUnreferencedCode("Loads a serializer named under [akka.actor.serializers] by name and activates it. The trimmer cannot tell which type that is, so it may have been trimmed away.")]
private static object CreateSerializerFromTypeName(string serializerTypeName, ExtendedActorSystem system, Config serializerConfig)

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.

again, return the concrete base type rather than object - although, how does this handle Serializer V1 vs V2?

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.

Done in 6cce8a4. CreateSerializerFromTypeName now returns SerializerV2: it passes the Activator result through AdaptSerializer, because a type named in HOCON is not guaranteed to be a serializer.

V1 vs V2: SerializerV2 derives from Serializer, and the constructor stores every serializer as a SerializerV2 via AdaptSerializer:

  • a SerializerV2 passes through unchanged (checked first)
  • a V1 Serializer is wrapped in SerializerV1Adapter
  • anything else throws the "must inherit from Serializer or SerializerV2" ArgumentException

ByteArraySerializer is already V2; NewtonSoftJsonSerializer is V1 and gets wrapped. Calling AdaptSerializer twice on the reflection path is a no-op the second time.

Also in this push (7c3c1ed): with dynamic type loading off, a SerializationSetup now covers the HOCON rows it takes over. Before, the HOCON loop threw on a module alias before the Setup loop ran, so the fix the error message suggests could never work.

@Aaronontheweb
Aaronontheweb removed this pull request from stack #8607 September 24, 2026 16:42
@Aaronontheweb
Aaronontheweb changed the base branch from feature/aot-m1-c-props-and-loggers to aot/m1-c-props-and-loggers September 24, 2026 16:42
@Aaronontheweb
Aaronontheweb added this pull request to stack #8632 September 24, 2026 16:43
Base automatically changed from aot/m1-c-props-and-loggers to dev September 24, 2026 16:44
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from 7c3c1ed to 55dd3e4 Compare September 24, 2026 16:44
…ving every key

SerializerIdentifierHelper.GetSerializerIdentifierFromConfig already holds the Type whose id it
wants, so it now formats that Type and compares it against each configured key instead of calling
Type.GetType(key, throwOnError: true) on every key in the block.

Each key is trimmed, run through TypeExtensions.StripAssemblyIdentity, then split at the comma
separating the type name from the assembly name - the first comma at bracket depth zero, so a closed
generic's own commas do not count. The type name compares Ordinal, the assembly name
OrdinalIgnoreCase, which is how Type.GetType compared assembly names. Assembly-qualified keys are
matched across the whole block first and bare FullName keys only afterwards, so a bare key cannot
shadow an exact qualified key further down the block.

That drops the last Type.GetType call on the serialization startup path and fixes a latent bug along
the way. Serialization..ctor reads Serializer.Identifier for every serializer it registers, so one
key naming a type the application cannot load - a serializer configured by a package that is not
referenced - took ActorSystem.Create down with it, however well-formed the other keys were. The spec
for it fails on dev inside ActorSystem.Create, not at the assertion.

StripAssemblyIdentity is the shared helper introduced with the feature switch, and TypeQualifiedName
builds on it too, so its output is a wire manifest. It was therefore measured rather than eyeballed
before this site started depending on it: old stripping against new 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 - is byte-identical in every case.

The narrowing and the two widenings are recorded in BREAKING_CHANGES_V1.6.md.
Akka.NET's own akka.conf declares two serializers under akka.actor.serializers - bytes and json - and
two bindings under akka.actor.serialization-bindings - System.Byte[] and System.Object. Both HOCON
loops resolved those names with Type.GetType, so the trimmer could not tell which types were being
loaded and dropped them; the unrooted AOT canary logged four "did not resolve" warnings at startup
and booted with no serializers and two dangling bindings.

Both loops now consult a BuiltIn* table first and construct the type directly. A name that misses the
table falls back to the old reflection code, moved into a private static method marked
[RequiresUnreferencedCode] and reachable only while dynamic type loading is on; with the switch off
the loop throws a ConfigurationException built by AkkaFeatures.NotBuiltIn.

BuiltInSerializers carries the bare and the assembly-qualified spelling of each name;
BuiltInSerializationBindings carries the bare spelling plus mscorlib, System.Private.CoreLib,
System.Runtime and netstandard, because these two are framework types with no single qualified
spelling and Type.GetType resolved all four. Versioned spellings need no keys of their own: the lookup
runs the configured name through StripAssemblyIdentity first. That is deliberately in preference to a
third key built from typeof(T).AssemblyQualifiedName - measured on the canary, the typeof keys cost 16
bytes (5,370,888 vs 5,370,872) and neither variant produced a NewtonSoftJsonSerializer.cs trim
warning, so the reason is not size but that stripping matches every version of a name where a
typeof-built key only matches the build it came from.

With the switch on nothing changes: json and the System.Object binding are registered exactly as
today. With it off, core does not register the json alias at all - Newtonsoft.Json is
reflection-driven from top to bottom, and leaving it out beats registering a serializer that throws
the first time anything is serialized. A type with no binding of its own then has no fallback
serializer and FindSerializerForType throws through the path allow-unregistered-types already had,
with an added sentence naming the switch and pointing at SerializationSetup, because "Serializer not
found for type Poco" on a default config is not something a user can act on.

Crucially, whether a built-in binding survives is decided by the alias it points at, not by the
feature switch. Skipping the System.Object binding from the switch silently broke the migration the
ledger recommends - a SerializationSetup alias plus "System.Object" = mine, where mine IS registered,
since setups are added before the bindings loop. The serializers loop now records any alias whose
built-in factory declined in a local skippedAliases set, and a binding whose target is missing warns
as before unless the target is in that set. So "System.Object" = json is dropped silently with the
switch off, "System.Object" = mine is honored, and "System.Object" = typo still warns.

ByteArraySerializer has only a (ExtendedActorSystem) constructor. On dev an
akka.actor.serialization-settings.bytes block pushed Activator.CreateInstance down the two-argument
branch and threw MissingMethodException; the table constructs it directly and ignores the block, which
is better and is ledgered. Serialization-bindings keys are trimmed - HOCON trims values but not keys,
and a binding's type name is the key - which is a widening, since Type.GetType rejected a padded bare
name. Two keys landing on the same Type stay harmless: AddSerializationMap is last-write-wins, as it
already was for the several spellings that resolved to the same Type on their own.

Recorded in BREAKING_CHANGES_V1.6.md.

The AOT canary asserted that a string resolved to a serializer, which was true only while json was
registered unconditionally. That assertion now pins the ruling instead of contradicting it: byte[]
must still resolve, and serializing an unbound type must throw a SerializationException whose message
names the feature switch and SerializationSetup. README pass condition updated to match.
… row and reuse the shared switch name

Serialization.cs: HOCON trims values but not keys. Trimming a
serialization-bindings key accepted padded keys that dev rejected, a
widening that has nothing to do with the built-in tables and that the
mailbox requirement keys deliberately do not make. Use kvp.Key as
written, as before.

Serializer.cs GetSerializerIdentifierFromConfig: same for
serialization-identifiers keys - drop the .Trim() before
StripAssemblyIdentity in both passes. The .TrimEnd()/.Trim() on the two
halves after the comma split stay; they are what turn "Ns.T, Akka" into
"Ns.T" and "Akka".

BREAKING_CHANGES_V1.6.md: drop the clause saying a padded
serialization-bindings key now resolves.

BuiltInSerializerDefaultsSpec.cs: use AkkaFeaturesSpec.SwitchName
instead of a local copy.
… object

The built-in factories and the reflection fallback now return the
serializer base type. CreateSerializerFromTypeName adapts its result, so
a type named in HOCON that is not a serializer still fails with the
AdaptSerializer message, and a V1 serializer is still wrapped.
… type loading is off

With Akka.DynamicTypeLoading off, the serializer loop threw on any HOCON
alias core could not resolve before the SerializationSetup loop ran, so
the fix the error message suggests could never work for a row that a
module's reference.conf still carries.

- A serializer row is skipped when a SerializationSetup registers the
  same alias; the Setup registers it right after.
- A binding row resolves without reflection when its type name matches
  one of the Setup's UseFor types (bare full name, name plus assembly,
  or a full assembly-qualified name).
- Any other row still throws. The switch-on path is unchanged.
@Aaronontheweb
Aaronontheweb force-pushed the aot/m1-d-serializer-defaults branch from 55dd3e4 to a1574c6 Compare September 25, 2026 00:46
@Aaronontheweb
Aaronontheweb merged commit 3756123 into dev Sep 25, 2026
15 checks passed
@Aaronontheweb
Aaronontheweb deleted the aot/m1-d-serializer-defaults 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