From 4a416efbd11cec1cd967cff81d70804391071c23 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 22 Sep 2026 21:22:47 +0000 Subject: [PATCH 1/3] Construct the built-in mailbox types and the mailbox requirement map without reflection Mailboxes read two kinds of HOCON type name through Type.GetType: the mailbox-type of every id under akka.actor.mailbox (plus akka.actor.default-mailbox), and the message queue semantics interfaces that are the keys of akka.actor.mailbox.requirements and the values a dispatcher's mailbox-requirement can take. All of those names are types core itself ships, so the trimmer had no way to tell which type was loaded and dropped it - the first unrooted Native AOT publish died in Mailboxes.LookupConfigurator with ArgumentException "Cannot instantiate MailboxType Akka.Dispatch.UnboundedMailbox, defined in [akka.actor.default-mailbox]", whose inner MissingMethodException was the trimmed (Settings, Config) constructor. Two tables now sit in front of those sites. BuiltInMailboxTypes holds a factory delegate per built-in MailboxType - UnboundedMailbox, BoundedMailbox, UnboundedDequeBasedMailbox, BoundedDequeBasedMailbox and LoggerMailboxType - and BuiltInMessageQueueSemantics maps the seven built-in message queue semantics interfaces. A name that misses its 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 built by AkkaFeatures.NotBuiltIn, so the wording matches every other converted site. Each table carries three spellings per type: akka.conf ships the bare name, HOCON in the wild also carries the "Ns.T, Akka" form, and typeof(T).AssemblyQualifiedName adds the version/culture/ public-key form that Akka.Hosting writes. typeof is a constant to the trimmer and to ILC, so the third spelling costs nothing and needs no string parsing. Each HOCON value is trimmed once, so the table lookup, the reflection fallback and the exception message all work off the same string. Behavior with the switch on is unchanged for every input, with three narrow exceptions: a built-in mailbox type is now constructed directly, so a constructor that rejects its config reports its own exception instead of the TargetInvocationException Activator.CreateInstance wrapped it in; a padded value now resolves where Type.GetType rejected it; and a whitespace-only value now counts as blank rather than as a type name. With the switch off, an unresolvable key under akka.actor.mailbox.requirements now throws rather than logging a warning and skipping that binding, which is the only switch-off difference that is more than an exception type. Recorded in BREAKING_CHANGES_V1.6.md. MailboxFeatureSwitchSpec covers every mailbox id core resolves on a default boot with the switch off, both non-literal spellings of a built-in mailbox-type, and a mailbox-type living outside Akka.dll with the switch both on and off. --- BREAKING_CHANGES_V1.6.md | 3 +- .../Dispatch/MailboxFeatureSwitchSpec.cs | 260 ++++++++++++++++++ src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs | 2 +- src/core/Akka/Dispatch/Mailboxes.cs | 186 +++++++++++-- 4 files changed, 424 insertions(+), 27 deletions(-) create mode 100644 src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs diff --git a/BREAKING_CHANGES_V1.6.md b/BREAKING_CHANGES_V1.6.md index 7254f5f6a11..c42a9f8591b 100644 --- a/BREAKING_CHANGES_V1.6.md +++ b/BREAKING_CHANGES_V1.6.md @@ -40,7 +40,8 @@ of `Behavior`, `Wire`, `API` (combine with `+`). | Status | PR / Branch | Component | Type | Change | Migration | |--------|-------------|-----------|------|--------|-----------| -| Planned | `aot/m1-a-feature-switch-and-first-tables` | `Akka` (core / actor system bootstrap) | Behavior | Adds the internal trimming / Native AOT feature switch `Akka.DynamicTypeLoading` (default **on**) and puts built-in lookup tables in front of the first three HOCON type-name sites: `akka.stdout-logger-class` and `akka.logger-formatter` in `Settings`, and `akka.scheduler.implementation` in `ActorSystemImpl.ConfigureScheduler`. Each site now checks a `BuiltIn*` table before falling back to `Type.GetType`. Each table carries two spellings of every name it knows -- the bare `Ns.T` and the `Ns.T, Akka` form, which is what core's own `akka.conf` uses -- and the lookup first runs the configured value through the new internal `Akka.Util.TypeExtensions.StripAssemblyIdentity`, which removes the `Version=`, `Culture=`, `PublicKeyToken=`, `ProcessorArchitecture=`, `Retargetable=` and `ContentType=` components. With the switch on, which is what every existing application gets, the same config resolves to the same type as before, with two narrow differences: (1) a built-in name is constructed directly, so a constructor that rejects its config (e.g. `akka.scheduler.tick-duration` below 10 ms, which `HashedWheelTimerScheduler` refuses) now surfaces its own `ArgumentOutOfRangeException` / `ConfigurationException` instead of the `TargetInvocationException` that `Activator.CreateInstance` wrapped it in; (2) a built-in type name written with a full assembly-qualified name of a different Akka version (what `Akka.Hosting` writes into HOCON, carried across an upgrade) now resolves where it previously threw. `Type.GetType` rejects the version mismatch, so on `dev` such a value fails in both switch states -- measured: `ArgumentException("Could not load type of ...")` for `akka.logger-formatter`/`akka.stdout-logger-class`, and `FileNotFoundException` for `akka.scheduler.implementation`, whose `Type.GetType` call passes `throwOnError: true`. Custom (non-built-in) names are unchanged, since the reflection fallback still receives the raw value. An application that turns the switch off with `` gets only the names in the tables: anything else throws a `ConfigurationException` naming the setting, the value and the switch, where it previously threw `TypeLoadException` (scheduler) or `ArgumentException` (stdout logger, log formatter). | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around `ActorSystem.Create` to read a rejected `akka.scheduler.*` value should catch the inner exception type instead. A trimmed or Native AOT application that turns the switch off must leave these three settings at their built-in values. | +| Planned | `aot/m1-b-mailboxes` | `Akka` (core / `Akka.Dispatch.Mailboxes`) | Behavior + API | `Mailboxes` resolves the mailbox names core's own `akka.conf` ships from two built-in tables (`BuiltInMailboxTypes`, `BuiltInMessageQueueSemantics`) instead of `Type.GetType`, so the trimmer and the Native AOT compiler can see which type is loaded. Two observable changes. (1) A built-in `mailbox-type` is constructed directly, so when its constructor rejects its config the `ArgumentException` from `Mailboxes.Lookup` carries that constructor's own exception as `InnerException` instead of the `TargetInvocationException` `Activator.CreateInstance` wrapped it in, and names the configured spelling (e.g. `Akka.Dispatch.BoundedMailbox, Akka`) where it previously named `Type.ToString()`; relatedly, the non-zero-push-timeout warning now runs outside that `try`, so an exception from a custom `IProducesPushTimeoutSemanticsMailbox.PushTimeout` getter propagates as itself rather than wrapped. (2) With the `Akka.DynamicTypeLoading` switch **off**, a key under `akka.actor.mailbox.requirements` that is neither built in nor resolvable now throws a `ConfigurationException` out of `ActorSystem.Create`, where the switch-on path still logs a warning and skips that one binding; an unknown `mailbox-type` or `mailbox-requirement` likewise throws a `ConfigurationException` naming the setting, the value and the switch, in place of the previous `ConfigurationException` / `TypeLoadException`. With the switch on - the default, and what every existing application gets - every other input resolves to the same mailbox as before. Separately, `MailboxType` and the `Type` parameters of `Mailboxes.HasRequiredType`, `Mailboxes.ProducesMessageQueue` and `Mailboxes.GetRequiredType` gain `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]` so that trimming keeps `IProducesMessageQueue<>` / `IRequiresMessageQueue<>` on subclasses. | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around a rejected `mailbox-capacity` / `mailbox-push-timeout-time` should catch the inner exception type instead, and code asserting on the exact `Cannot instantiate MailboxType ...` text should expect the configured spelling for the five built-in types. A trimmed or Native AOT application that turns the switch off must keep `mailbox-type`, `mailbox-requirement` and every key under `akka.actor.mailbox.requirements` at built-in values. The new attributes are visible only to trim analysis -- they change no signature a compiler binds to -- but a caller that has trim analysis enabled and passes an unannotated `Type` into those three methods now gets `IL2072` at its own call site: annotate the `Type` it flows from with `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]`, or pass a `typeof(...)`. | +| Planned | `aot/m1-a-feature-switch-and-first-tables` | `Akka` (core / actor system bootstrap) | Behavior | Adds the internal trimming / Native AOT feature switch `Akka.DynamicTypeLoading` (default **on**) and puts built-in lookup tables in front of the first three HOCON type-name sites: `akka.stdout-logger-class` and `akka.logger-formatter` in `Settings`, and `akka.scheduler.implementation` in `ActorSystemImpl.ConfigureScheduler`. Each site now checks a `BuiltIn*` table before falling back to `Type.GetType`. Each table carries three spellings of every name it knows -- the bare `Ns.T`, the `Ns.T, Akka` form, and `typeof(T).AssemblyQualifiedName` (the `Version=`/`Culture=`/`PublicKeyToken=` form that `Akka.Hosting`'s `LoggerConfigBuilder` writes into HOCON) -- because core's own `akka.conf` and the Hosting packages between them use all three. With the switch on, which is what every existing application gets, the same config resolves to the same type as before, with two narrow differences: (1) a built-in name is constructed directly, so a constructor that rejects its config (e.g. `akka.scheduler.tick-duration` below 10 ms, which `HashedWheelTimerScheduler` refuses) now surfaces its own `ArgumentOutOfRangeException` / `ConfigurationException` instead of the `TargetInvocationException` that `Activator.CreateInstance` wrapped it in; (2) each of the three values is trimmed once before it is used - for the table lookup, the reflection fallback and the exception message alike - so a bare name with trailing whitespace now resolves where `Type.GetType` returned `null`, uniformly for built-in and custom names (`Type.GetType` already tolerated leading whitespace, and trailing whitespace on an assembly-qualified name). An application that turns the switch off with `` gets only the names in the tables: anything else throws a `ConfigurationException` naming the setting, the value and the switch, where it previously threw `TypeLoadException` (scheduler) or `ArgumentException` (stdout logger, log formatter). | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around `ActorSystem.Create` to read a rejected `akka.scheduler.*` value should catch the inner exception type instead. A trimmed or Native AOT application that turns the switch off must leave these three settings at their built-in values. | | Planned | `fix/aot-provider-constant-type-names` | `Akka` (core / actor system bootstrap) | Behavior | `ActorSystemImpl` now resolves the three built-in actor ref providers from the compile-time constant type names on `ProviderSelection`, with the `Type.GetType` call inlined at each site, so the trimmer and the Native AOT compiler can see which type is loaded and keep it. Two observable consequences: (1) `ProviderSelection.GetProvider` now maps the local provider's type name -- both the bare `"Akka.Actor.LocalActorRefProvider"` that ships in `akka.conf` and the assembly-qualified `"Akka.Actor.LocalActorRefProvider, Akka"` -- onto `ProviderSelection.Local` instead of letting it fall through to `Custom`, so `Settings.ProviderClass` on a default boot now reports `"Akka.Actor.LocalActorRefProvider, Akka"` where it previously reported the bare `"Akka.Actor.LocalActorRefProvider"`. (2) `Settings` only validates `ProviderClass` through `Type.GetType` when the selection is `ProviderSelection.Custom`; the built-in three are validated where they are constructed, and a missing `Akka.Remote` / `Akka.Cluster` reference now throws `ConfigurationException("akka.actor.provider = cluster, but Akka.Cluster is not referenced by this application.")` from `ActorSystemImpl` rather than the generic "not a valid type name" message from `Settings`. Which provider gets constructed for any given config is unchanged. | None for normal use. Code that compares `Settings.ProviderClass` against the bare string `"Akka.Actor.LocalActorRefProvider"` should compare against `ProviderSelection.Local.Instance.Fqn`, or test `Settings.ProviderSelectionType is ProviderSelection.Local`. Code that asserted on the exact text of the misconfiguration exception for `provider = remote` / `provider = cluster` should expect the new message. | | Planned | `feature/hosting-import-dev` (forward-port of [#8591](https://github.com/akkadotnet/akka.net/pull/8591)) | `Akka.Hosting`, `Akka.Remote.Hosting`, `Akka.Cluster.Hosting`, `Akka.Persistence.Hosting`, `Akka.Hosting.TestKit`, `Akka.Hosting.TestKit.Xunit2` | API | The Akka.Hosting packages now ship from this repository at the Akka.NET version. On 1.6 they target `net10.0` only (previously `netstandard2.0;net6.0`), matching the rest of Akka.NET 1.6. `Microsoft.Extensions.*` floor moves to 10.0.0 for `Akka`, `Akka.DependencyInjection` and the Hosting packages. | Reference `Akka.Hosting.*` at the same version as `Akka.*`. Applications must target `net10.0` or later, as for every other Akka.NET 1.6 package. | | Planned | `fix/testkit-xunit-async-dispose-chain` | `Akka.TestKit.Xunit` | API + Behavior | `Akka.TestKit.Xunit.TestKit` now also implements `Xunit.IAsyncLifetime`, adding `virtual ValueTask InitializeAsync()` and `virtual ValueTask DisposeAsync()`. This closes #8191: xUnit v3 tears a test class instance down via `IAsyncDisposable.DisposeAsync()` in preference to `IDisposable.Dispose()` whenever a type implements both, so a derived spec that declared its own `IAsyncLifetime.DisposeAsync()` (to run its own async teardown) short-circuited `TestKit.Dispose(bool)` entirely and silently leaked its `ActorSystem` -- DotNetty threads, a bound socket, and a `PeriodicTimer`-driven scheduler included. `TestKit.DisposeAsync()` now runs the synchronous dispose chain (`AfterAll()` etc.) and then shuts the system down with the non-blocking `ShutdownAsync()` instead of the blocking `Shutdown()`. | A derived class that already declares its own `InitializeAsync`/`DisposeAsync` (whether via `IAsyncLifetime` directly or by re-declaring the method) now hides a virtual member of the same name and gets `CS0114`, which is an error under this repo's own `-warnaserror` build. Add `override` to each and chain to the base (`await base.InitializeAsync();` / `await base.DisposeAsync();`), and drop the redundant `IAsyncLifetime` interface declaration and any no-op `DisposeAsync` that only returned a completed `ValueTask`. | diff --git a/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs b/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs new file mode 100644 index 00000000000..2712154f2a1 --- /dev/null +++ b/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs @@ -0,0 +1,260 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Setup; +using Akka.Configuration; +using Akka.Dispatch; +using Akka.Event; +using Akka.Tests.Util; +using FluentAssertions; +using Xunit; + +namespace Akka.Tests.Dispatch +{ + /// + /// Covers against the Akka.DynamicTypeLoading feature switch: everything + /// core's own akka.conf names has to resolve with the switch off, and anything that does not ship + /// inside Akka.dll has to resolve with it on and be refused with it off. + /// + [Collection(DynamicTypeLoadingCollection.Name)] + public sealed class MailboxFeatureSwitchSpec + { + private readonly ITestOutputHelper _output; + + public MailboxFeatureSwitchSpec(ITestOutputHelper output) + { + _output = output; + } + + /// + /// lives in Akka.Tests, so it is by definition not one of the mailbox + /// types built into Akka.dll - the only way to reach it is . + /// + private const string CustomMailboxTypeName = "Akka.Tests.Dispatch.TestPriorityMailbox"; + + private const string CustomMailboxConfig = @" + custom-mailbox { + mailbox-type = """ + CustomMailboxTypeName + @", Akka.Tests"" + }"; + + /// + /// Every mailbox id core resolves on a default boot, mapped to the its + /// mailbox-type names. + /// + private static readonly IReadOnlyList<(string Id, Type Expected)> BuiltInMailboxIds = new[] + { + (Mailboxes.DefaultMailboxId, typeof(UnboundedMailbox)), + ("akka.actor.mailbox.unbounded-queue-based", typeof(UnboundedMailbox)), + ("akka.actor.mailbox.bounded-queue-based", typeof(BoundedMailbox)), + ("akka.actor.mailbox.unbounded-deque-based", typeof(UnboundedDequeBasedMailbox)), + ("akka.actor.mailbox.bounded-deque-based", typeof(BoundedDequeBasedMailbox)), + ("akka.actor.mailbox.logger-queue", typeof(LoggerMailboxType)), + + // "unbounded" and "bounded" are not config ids at all - they are the two shortcut arms + // LookupConfigurator answers by constructing a mailbox directly, which this change does not + // touch. They are here so a later refactor of those arms cannot quietly break the switch-off + // path with them. + ("unbounded", typeof(UnboundedMailbox)), + ("bounded", typeof(BoundedMailbox)) + }; + + [Fact(DisplayName = "Mailboxes should resolve every mailbox id core ships when dynamic type loading is off")] + public async Task Should_resolve_every_built_in_mailbox_id_When_dynamic_type_loading_is_disabled() + { + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => + { + var system = ActorSystem.Create("built-in-mailboxes-off"); + try + { + foreach (var (id, expected) in BuiltInMailboxIds) + { + var mailboxType = system.Mailboxes.Lookup(id); + _output.WriteLine($"{id} -> {mailboxType.GetType().Name}"); + mailboxType.Should().BeOfType(expected, $"[{id}] must resolve from the built-in table"); + } + } + finally + { + await system.Terminate(); + } + }); + } + + /// + /// Akka.Hosting writes the full versioned assembly-qualified name into HOCON. The table holds only the + /// bare and "Ns.T, Akka" spellings, so this is the case that pins the + /// TypeExtensions.StripAssemblyIdentity normalization on the lookup. The second row names a + /// version this build will never have, which is the whole reason the table has no versioned key. + /// + [Fact(DisplayName = "Mailboxes should resolve a built-in mailbox-type written as a full assembly-qualified name when dynamic type loading is off")] + public async Task Should_resolve_an_assembly_qualified_mailbox_type_When_dynamic_type_loading_is_disabled() + { + var config = ConfigurationFactory.ParseString($@" + aqn-mailbox {{ + mailbox-type = ""{typeof(UnboundedDequeBasedMailbox).AssemblyQualifiedName}"" + }} + foreign-version-mailbox {{ + mailbox-type = ""Akka.Dispatch.BoundedDequeBasedMailbox, Akka, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"" + }}"); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => + { + var system = ActorSystem.Create("aqn-mailbox-off", config); + try + { + system.Mailboxes.Lookup("aqn-mailbox").Should().BeOfType(); + system.Mailboxes.Lookup("foreign-version-mailbox").Should().BeOfType(); + } + finally + { + await system.Terminate(); + } + }); + } + + /// + /// The explicit switch-ON regression guard for mailbox-type: the reflection fallback must still + /// resolve a that BuiltInMailboxTypes knows nothing about. + /// + [Fact(DisplayName = "Mailboxes should resolve a mailbox-type named in HOCON when dynamic type loading is on")] + public async Task Should_resolve_a_custom_mailbox_type_When_dynamic_type_loading_is_enabled() + { + await AkkaFeaturesSpec.WithDynamicTypeLoading(true, async () => + { + var system = ActorSystem.Create( + "custom-mailbox-on", + ConfigurationFactory.ParseString(CustomMailboxConfig)); + try + { + system.Mailboxes.Lookup("custom-mailbox").Should().BeOfType(); + } + finally + { + await system.Terminate(); + } + }); + } + + [Fact(DisplayName = "Mailboxes should reject a mailbox-type that is not built in when dynamic type loading is off")] + public async Task Should_throw_ConfigurationException_When_the_mailbox_type_is_not_built_in_and_dynamic_type_loading_is_disabled() + { + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => + { + var system = ActorSystem.Create( + "custom-mailbox-off", + ConfigurationFactory.ParseString(CustomMailboxConfig)); + try + { + var exception = Assert.Throws( + () => system.Mailboxes.Lookup("custom-mailbox")); + + _output.WriteLine(exception.Message); + exception.Message.Should().Contain("custom-mailbox.mailbox-type"); + exception.Message.Should().Contain(CustomMailboxTypeName); + exception.Message.Should().Contain(DynamicTypeLoadingCollection.Name); + } + finally + { + await system.Terminate(); + } + }); + } + + [Fact(DisplayName = "Mailboxes should reject an akka.actor.mailbox.requirements key that is not built in when dynamic type loading is off")] + public async Task Should_throw_ConfigurationException_When_a_requirement_key_is_not_built_in_and_dynamic_type_loading_is_disabled() + { + var config = ConfigurationFactory.ParseString(@" + akka.actor.mailbox.requirements { + ""Some.Unknown.Interface"" = akka.actor.mailbox.unbounded-queue-based + }"); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, () => + { + // Mailboxes is constructed during ActorSystem.Create, so the throw surfaces from there + var exception = Assert.Throws( + () => ActorSystem.Create("unknown-requirement-off", config)); + + _output.WriteLine(exception.Message); + exception.Message.Should().Contain("akka.actor.mailbox.requirements"); + exception.Message.Should().Contain("Some.Unknown.Interface"); + exception.Message.Should().Contain(DynamicTypeLoadingCollection.Name); + return Task.CompletedTask; + }); + } + + /// + /// The switch-ON contract for akka.actor.mailbox.requirements: a key that does not resolve is + /// warned about and skipped, and the system still boots. The key here is a built-in interface name + /// with a trailing space, which is the case that pins the requirement keys as NOT trimmed - trimming + /// them would map this key onto the same as core's own unpadded key and make the + /// binding dictionary throw out of . + /// + [Fact(DisplayName = "Mailboxes should warn and skip an akka.actor.mailbox.requirements key that does not resolve when dynamic type loading is on")] + public async Task Should_warn_and_skip_an_unresolvable_requirement_key_When_dynamic_type_loading_is_enabled() + { + const string paddedBuiltInKey = "Akka.Dispatch.IUnboundedMessageQueueSemantics "; + var config = ConfigurationFactory.ParseString($@" + akka.actor.mailbox.requirements {{ + ""{paddedBuiltInKey}"" = akka.actor.mailbox.unbounded-queue-based + }}"); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(true, async () => + { + // Mailboxes warns while ActorSystem.Create is still running, so no EventStream subscriber can + // be in place yet; a log filter is the only way to observe it. + var warnings = new RecordingLogFilter(); + var system = ActorSystem.Create( + "padded-requirement-on", + ActorSystemSetup.Create( + BootstrapSetup.Create().WithConfig(config), + new LogFilterSetup([warnings]))); + try + { + foreach (var warning in warnings.Messages) + _output.WriteLine(warning); + + warnings.Messages.Should().Contain(m => m.Contains($"Mailbox Requirement mapping [{paddedBuiltInKey}]")); + + // core's own unpadded binding is untouched + system.Mailboxes.LookupByQueueType(typeof(IUnboundedMessageQueueSemantics)) + .Should().BeOfType(); + } + finally + { + await system.Terminate(); + } + }); + } + + /// + /// Records every WARNING the stdout logger is asked to print and keeps them all, so a spec can assert + /// on warnings raised before the has any subscribers. + /// + private sealed class RecordingLogFilter : LogFilterBase + { + private readonly ConcurrentQueue _messages = new(); + + public IReadOnlyCollection Messages => _messages; + + public override LogFilterType FilterType => LogFilterType.Content; + + public override LogFilterDecision ShouldKeepMessage(LogEvent content, string? expandedMessage = null) + { + if (content.LogLevel() == LogLevel.WarningLevel) + _messages.Enqueue(expandedMessage ?? content.Message?.ToString() ?? string.Empty); + + return LogFilterDecision.Keep; + } + } + } +} diff --git a/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs b/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs index 85df8b7484e..899f301a178 100644 --- a/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs +++ b/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs @@ -55,7 +55,7 @@ public class AkkaFeaturesSpec /// true - which is the value reports /// for an unset switch anyway. /// - private static async Task WithDynamicTypeLoading(bool enabled, Func body) + internal static async Task WithDynamicTypeLoading(bool enabled, Func body) { var hadSwitch = AppContext.TryGetSwitch(SwitchName, out var previous); AppContext.SetSwitch(SwitchName, enabled); diff --git a/src/core/Akka/Dispatch/Mailboxes.cs b/src/core/Akka/Dispatch/Mailboxes.cs index 2ee64154c70..790e3095da3 100644 --- a/src/core/Akka/Dispatch/Mailboxes.cs +++ b/src/core/Akka/Dispatch/Mailboxes.cs @@ -9,6 +9,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Akka.Actor; @@ -45,6 +46,61 @@ public class Mailboxes private readonly ConcurrentDictionary _mailboxTypeConfigurators = new(); + // The mailbox-type values that ship inside Akka.dll, constructed directly so the trimmer and the + // Native AOT compiler can see the type without looking through Type.GetType. + // + // Two spellings per type, both deliberate: akka.conf ships the bare name and HOCON in the wild also + // carries the "Ns.T, Akka" form. The lookup runs the configured value through + // TypeExtensions.StripAssemblyIdentity first, so a full AssemblyQualifiedName - which Akka.Hosting + // writes into HOCON - matches the second key whatever version, culture or public key token it names. + // A value that still misses the table falls through to the reflection path, which is unavailable (and + // therefore throws) once dynamic type loading is switched off. Do not remove a spelling, and do not + // add a versioned third key. + private static readonly Dictionary> BuiltInMailboxTypes = + new(StringComparer.Ordinal) + { + ["Akka.Dispatch.UnboundedMailbox"] = static (s, c) => new UnboundedMailbox(s, c), + ["Akka.Dispatch.UnboundedMailbox, Akka"] = static (s, c) => new UnboundedMailbox(s, c), + ["Akka.Dispatch.BoundedMailbox"] = static (s, c) => new BoundedMailbox(s, c), + ["Akka.Dispatch.BoundedMailbox, Akka"] = static (s, c) => new BoundedMailbox(s, c), + ["Akka.Dispatch.UnboundedDequeBasedMailbox"] = static (s, c) => new UnboundedDequeBasedMailbox(s, c), + ["Akka.Dispatch.UnboundedDequeBasedMailbox, Akka"] = static (s, c) => new UnboundedDequeBasedMailbox(s, c), + ["Akka.Dispatch.BoundedDequeBasedMailbox"] = static (s, c) => new BoundedDequeBasedMailbox(s, c), + ["Akka.Dispatch.BoundedDequeBasedMailbox, Akka"] = static (s, c) => new BoundedDequeBasedMailbox(s, c), + ["Akka.Event.LoggerMailboxType"] = static (s, c) => new LoggerMailboxType(s, c), + ["Akka.Event.LoggerMailboxType, Akka"] = static (s, c) => new LoggerMailboxType(s, c) + }; + + // The message queue semantics interfaces that ship inside Akka.dll: the keys akka.conf lists under + // akka.actor.mailbox.requirements, which are also the values a dispatcher's mailbox-requirement can + // take. Mapping them here means neither site has to look through Type.GetType. + // + // Two spellings per interface, both deliberate: akka.conf ships the bare name and HOCON in the wild + // also carries the "Ns.T, Akka" form. Both lookups run their value through + // TypeExtensions.StripAssemblyIdentity first, so a full AssemblyQualifiedName - which Akka.Hosting + // writes into HOCON - matches the second key whatever version, culture or public key token it names. + // A value that still misses the table falls through to the reflection path, which is unavailable (and + // therefore throws) once dynamic type loading is switched off. Do not remove a spelling, and do not + // add a versioned third key. + private static readonly Dictionary BuiltInMessageQueueSemantics = + new(StringComparer.Ordinal) + { + ["Akka.Dispatch.IUnboundedMessageQueueSemantics"] = typeof(IUnboundedMessageQueueSemantics), + ["Akka.Dispatch.IUnboundedMessageQueueSemantics, Akka"] = typeof(IUnboundedMessageQueueSemantics), + ["Akka.Dispatch.IBoundedMessageQueueSemantics"] = typeof(IBoundedMessageQueueSemantics), + ["Akka.Dispatch.IBoundedMessageQueueSemantics, Akka"] = typeof(IBoundedMessageQueueSemantics), + ["Akka.Dispatch.IDequeBasedMessageQueueSemantics"] = typeof(IDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IDequeBasedMessageQueueSemantics, Akka"] = typeof(IDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IUnboundedDequeBasedMessageQueueSemantics"] = typeof(IUnboundedDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IUnboundedDequeBasedMessageQueueSemantics, Akka"] = typeof(IUnboundedDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IBoundedDequeBasedMessageQueueSemantics"] = typeof(IBoundedDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IBoundedDequeBasedMessageQueueSemantics, Akka"] = typeof(IBoundedDequeBasedMessageQueueSemantics), + ["Akka.Dispatch.IMultipleConsumerSemantics"] = typeof(IMultipleConsumerSemantics), + ["Akka.Dispatch.IMultipleConsumerSemantics, Akka"] = typeof(IMultipleConsumerSemantics), + ["Akka.Event.ILoggerMessageQueueSemantics"] = typeof(ILoggerMessageQueueSemantics), + ["Akka.Event.ILoggerMessageQueueSemantics, Akka"] = typeof(ILoggerMessageQueueSemantics) + }; + private Settings Settings => _system.Settings; /// @@ -63,12 +119,33 @@ public Mailboxes(ActorSystem system) _mailboxBindings = new Dictionary(); foreach (var kvp in requirements) { - var type = Type.GetType(kvp.Key); - if (type == null) + // The key is used verbatim everywhere except the table lookup: HOCON trims values but not + // keys, so normalizing it before _mailboxBindings.Add below would let two spellings of the + // same interface map onto one Type and collide. + var requirementName = kvp.Key; + + Type type; + if (BuiltInMessageQueueSemantics.TryGetValue( + Util.TypeExtensions.StripAssemblyIdentity(requirementName), out var builtIn)) { - Warn($"Mailbox Requirement mapping [{kvp.Key}] is not an actual type"); - continue; + type = builtIn; } + else if (AkkaFeatures.IsDynamicTypeLoadingSupported) + { + type = ResolveRequirementMappingType(requirementName); + if (type == null) + { + Warn($"Mailbox Requirement mapping [{requirementName}] is not an actual type"); + continue; + } + } + else + { + throw new ConfigurationException(AkkaFeatures.NotBuiltIn( + $"akka.actor.mailbox.requirements.\"{requirementName}\"", requirementName, + "a built-in message queue semantics interface as the requirement key, or remove the binding")); + } + _mailboxBindings.Add(type, kvp.Value.GetString()); } @@ -173,31 +250,41 @@ private MailboxType LookupConfigurator(string id) var mailboxTypeName = conf.GetString("mailbox-type", null); if (string.IsNullOrEmpty(mailboxTypeName)) throw new ConfigurationException($"The setting mailbox-type defined in [{id}] is empty"); - var mailboxType = Type.GetType(mailboxTypeName) - ?? throw new ConfigurationException($"Found mailbox-type [{mailboxTypeName}] in configuration for [{id}], but could not find that type in any loaded assemblies."); - var args = new object[] { Settings, conf }; - try - { - configurator = (MailboxType)Activator.CreateInstance(mailboxType, args); - if (!_mailboxNonZeroPushTimeoutWarningIssued) + if (BuiltInMailboxTypes.TryGetValue( + Util.TypeExtensions.StripAssemblyIdentity(mailboxTypeName), out var builtIn)) + { + try { - if (configurator is IProducesPushTimeoutSemanticsMailbox m && m.PushTimeout.Ticks > 0L) - { - Warn($"Configured potentially-blocking mailbox [{id}] configured with non-zero PushTimeOut ({m.PushTimeout}), " + - "which can lead to blocking behavior when sending messages to this mailbox. " + - $"Avoid this by setting `{id}.mailbox-push-timeout-time` to `0`."); - - _mailboxNonZeroPushTimeoutWarningIssued = true; - } - - // good; nothing to see here, move along, sir. + configurator = builtIn(Settings, conf); + } + catch (Exception ex) + { + throw CannotInstantiate(mailboxTypeName, id, ex); } } - catch (Exception ex) + else if (AkkaFeatures.IsDynamicTypeLoadingSupported) + { + configurator = CreateMailboxType(mailboxTypeName, id, Settings, conf); + } + else + { + throw new ConfigurationException(AkkaFeatures.NotBuiltIn( + $"{id}.mailbox-type", mailboxTypeName, "one of the built-in mailbox types")); + } + + if (!_mailboxNonZeroPushTimeoutWarningIssued) { - throw new ArgumentException($"Cannot instantiate MailboxType {mailboxType}, defined in [{id}]. Make sure it has a public " + - "constructor with [Akka.Actor.Settings, Akka.Configuration.Config] parameters", ex); + if (configurator is IProducesPushTimeoutSemanticsMailbox m && m.PushTimeout.Ticks > 0L) + { + Warn($"Configured potentially-blocking mailbox [{id}] configured with non-zero PushTimeOut ({m.PushTimeout}), " + + "which can lead to blocking behavior when sending messages to this mailbox. " + + $"Avoid this by setting `{id}.mailbox-push-timeout-time` to `0`."); + + _mailboxNonZeroPushTimeoutWarningIssued = true; + } + + // good; nothing to see here, move along, sir. } } @@ -208,6 +295,35 @@ private MailboxType LookupConfigurator(string id) return configurator; } + [RequiresUnreferencedCode("Loads a key of [akka.actor.mailbox.requirements] by name. The trimmer cannot tell which type that is, so it may have been trimmed away.")] + private static Type ResolveRequirementMappingType(string typeName) + { + return Type.GetType(typeName); + } + + [RequiresUnreferencedCode("Loads the configured [mailbox-type] by name and activates it. The trimmer cannot tell which type that is, so it may have been trimmed away.")] + private static MailboxType CreateMailboxType(string mailboxTypeName, string id, Settings settings, Config conf) + { + var mailboxType = Type.GetType(mailboxTypeName) + ?? throw new ConfigurationException($"Found mailbox-type [{mailboxTypeName}] in configuration for [{id}], but could not find that type in any loaded assemblies."); + + try + { + return (MailboxType)Activator.CreateInstance(mailboxType, new object[] { settings, conf }); + } + catch (Exception ex) + { + throw CannotInstantiate(mailboxType.ToString(), id, ex); + } + } + + // Both arms of LookupConfigurator report an unconstructable mailbox the same way. What differs is + // the name: the built-in arm has only the configured spelling, while the reflection arm has resolved + // a Type and reports Type.ToString(). + private static ArgumentException CannotInstantiate(string mailboxTypeName, string id, Exception inner) + => new($"Cannot instantiate MailboxType {mailboxTypeName}, defined in [{id}]. Make sure it has a public " + + "constructor with [Akka.Actor.Settings, Akka.Configuration.Config] parameters", inner); + /// /// INTERNAL API /// @@ -261,7 +377,27 @@ private Type GetProducedMessageQueueType(MailboxType mailboxType) private Type GetMailboxRequirement(Config config) { var mailboxRequirement = config.GetString("mailbox-requirement", null); - return mailboxRequirement == null || mailboxRequirement.Equals(NoMailboxRequirement) ? typeof (IMessageQueue) : Type.GetType(mailboxRequirement, true); + if (string.IsNullOrEmpty(mailboxRequirement) || mailboxRequirement.Equals(NoMailboxRequirement)) + return typeof(IMessageQueue); + + if (BuiltInMessageQueueSemantics.TryGetValue( + Util.TypeExtensions.StripAssemblyIdentity(mailboxRequirement), out var requirementType)) + return requirementType; + + if (AkkaFeatures.IsDynamicTypeLoadingSupported) + return ResolveMailboxRequirementType(mailboxRequirement); + + var dispatcherId = config.GetString("id", null); + throw new ConfigurationException(AkkaFeatures.NotBuiltIn( + dispatcherId is null ? "mailbox-requirement" : $"{dispatcherId}.mailbox-requirement", + mailboxRequirement, + "one of the built-in message queue semantics interfaces")); + } + + [RequiresUnreferencedCode("Loads the configured [mailbox-requirement] by name. The trimmer cannot tell which type that is, so it may have been trimmed away.")] + private static Type ResolveMailboxRequirementType(string typeName) + { + return Type.GetType(typeName, true); } /// From 6c6290f6910b5276596e4136765275b85b62b5a7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 22 Sep 2026 21:45:20 +0000 Subject: [PATCH 2/3] Keep mailbox interface metadata across the Mailboxes and MailboxType boundaries Mailboxes decides which message queue an actor gets by reading interface lists off Types that arrive from somewhere else: HasRequiredType and GetRequiredType look for IRequiresMessageQueue on an actor type handed in by Props, and ProducesMessageQueue and GetProducedMessageQueueType look for IProducesMessageQueue on a MailboxType. The trimmer cannot see back to where those Types came from, so it trimmed the interface lists and left the lookups finding nothing - the unrooted Native AOT canary failed with ArgumentException "No IProducesMessageQueue supplied for Akka.Event.LoggerMailboxType" while starting the DefaultLogger actor. [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] on the three Type parameters, and on MailboxType at the class level so every subclass inherits it, is what tells the trimmer to keep those interfaces. This is an attributes-only change to the public surface - no signature a compiler binds to moves - but the attributes do show up in the API approval, so CoreAPISpec.ApproveCore is re-approved here. The one consequence for callers is at their own call site: a caller with trim analysis enabled that passes an unannotated Type into these three methods now gets IL2072 and has to annotate the Type it flows from, or pass a typeof(...). --- ...oreAPISpec.ApproveCore.DotNet.verified.txt | 9 +++--- src/core/Akka/Dispatch/Mailbox.cs | 7 +++++ src/core/Akka/Dispatch/Mailboxes.cs | 30 ++++++++++++++----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 43cbef797d0..20b59a6381b 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -2884,6 +2884,7 @@ namespace Akka.Dispatch [System.Diagnostics.Conditional("MAILBOXDEBUG")] public static void DebugPrint(string message, params object[] args) { } } + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] public abstract class MailboxType { protected readonly Akka.Configuration.Config Config; @@ -2898,11 +2899,11 @@ namespace Akka.Dispatch public Mailboxes(Akka.Actor.ActorSystem system) { } public Akka.Actor.DeadLetterMailbox DeadLetterMailbox { get; } public Akka.Dispatch.MailboxType GetMailboxType(Akka.Actor.Props props, Akka.Configuration.Config dispatcherConfig) { } - public System.Type GetRequiredType(System.Type actorType) { } - public bool HasRequiredType(System.Type actorType) { } + public System.Type GetRequiredType([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] System.Type actorType) { } + public bool HasRequiredType([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] System.Type actorType) { } public Akka.Dispatch.MailboxType Lookup(string id) { } public Akka.Dispatch.MailboxType LookupByQueueType(System.Type queueType) { } - public bool ProducesMessageQueue(System.Type mailboxType) { } + public bool ProducesMessageQueue([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] System.Type mailboxType) { } [Akka.Annotations.InternalApi] public int StashCapacity(string dispatcher, string mailbox) { } } @@ -5727,4 +5728,4 @@ namespace Akka.Util.Reflection { public static System.Type GetType(string typeName) { } } -} +} \ No newline at end of file diff --git a/src/core/Akka/Dispatch/Mailbox.cs b/src/core/Akka/Dispatch/Mailbox.cs index 7084a5df767..005d60b72bc 100644 --- a/src/core/Akka/Dispatch/Mailbox.cs +++ b/src/core/Akka/Dispatch/Mailbox.cs @@ -7,6 +7,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Threading; @@ -590,7 +591,13 @@ public void Execute() /// dummy queue to the real mailbox. /// /// Implemented as an abstract class in order to enforce constructor requirements. + /// + /// Annotated with because + /// Mailboxes.ProducesMessageQueue and Mailboxes.GetProducedMessageQueueType read the interface + /// list off whichever they are handed, so trimming has to keep + /// on every subclass. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] public abstract class MailboxType { /// diff --git a/src/core/Akka/Dispatch/Mailboxes.cs b/src/core/Akka/Dispatch/Mailboxes.cs index 790e3095da3..c85503faf58 100644 --- a/src/core/Akka/Dispatch/Mailboxes.cs +++ b/src/core/Akka/Dispatch/Mailboxes.cs @@ -161,9 +161,12 @@ public Mailboxes(ActorSystem system) /// /// Check if this actor class can have a required message queue type. /// - /// The type to check. + /// + /// The type to check. Annotated with so that trimming + /// keeps on the actor type the caller passes in. + /// /// true if this actor has a message queue type requirement. false otherwise. - public bool HasRequiredType(Type actorType) + public bool HasRequiredType([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type actorType) { var interfaces = actorType.GetInterfaces(); for (int i = 0; i < interfaces.Length; i++) @@ -181,9 +184,13 @@ public bool HasRequiredType(Type actorType) /// /// Check if this implements the interface. /// - /// The type of the to check. + /// + /// The type of the to check. Annotated with + /// so that trimming keeps + /// on the mailbox type the caller passes in. + /// /// true if this mailboxtype produces queues. false otherwise. - public bool ProducesMessageQueue(Type mailboxType) + public bool ProducesMessageQueue([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type mailboxType) { var interfaces = mailboxType.GetInterfaces(); for (int i = 0; i < interfaces.Length; i++) @@ -339,11 +346,18 @@ private Config Config(string id) private static readonly Type RequiresMessageQueueGenericType = typeof (IRequiresMessageQueue<>); /// - /// TBD + /// Returns the type this actor class requires via + /// , or null if it does not require one. /// - /// TBD - /// TBD - public Type GetRequiredType(Type actorType) + /// + /// The actor type to inspect. Annotated with so that + /// trimming keeps on the actor type the caller passes in. + /// + /// + /// The required message queue type, or null when does not implement + /// . + /// + public Type GetRequiredType([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type actorType) { var interfaces = actorType.GetInterfaces(); for (int i = 0; i < interfaces.Length; i++) From ba2667d172f34a29c77b88e4487fa47ae7eb2113 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 23 Sep 2026 14:29:36 +0000 Subject: [PATCH 3/3] Restore the A ledger row that B's rebase replaced with a superseded draft, and fix B's own regressions BREAKING_CHANGES_V1.6.md: B's diff had replaced A's row with an earlier, superseded draft of that same row (the "three spellings" / "typeof(T).AssemblyQualifiedName" / "trimmed once before it is used" text). Restore the row exactly as it reads on aot/m1-a-feature-switch-and-first-tables. Mailboxes.GetMailboxRequirement: revert the string.IsNullOrEmpty(mailboxRequirement) check back to mailboxRequirement == null, matching dev parity - an empty mailbox-requirement string must keep throwing (switch on via Type.GetType("", true), switch off via NotBuiltIn), not silently fall back to IMessageQueue. MailboxFeatureSwitchSpec: the two exception-message assertions that checked for DynamicTypeLoadingCollection.Name (the xunit collection name, not the AppContext switch name) now check AkkaFeaturesSpec.SwitchName instead. --- BREAKING_CHANGES_V1.6.md | 2 +- src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs | 4 ++-- src/core/Akka/Dispatch/Mailboxes.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/BREAKING_CHANGES_V1.6.md b/BREAKING_CHANGES_V1.6.md index c42a9f8591b..e2fbc50b313 100644 --- a/BREAKING_CHANGES_V1.6.md +++ b/BREAKING_CHANGES_V1.6.md @@ -41,7 +41,7 @@ of `Behavior`, `Wire`, `API` (combine with `+`). | Status | PR / Branch | Component | Type | Change | Migration | |--------|-------------|-----------|------|--------|-----------| | Planned | `aot/m1-b-mailboxes` | `Akka` (core / `Akka.Dispatch.Mailboxes`) | Behavior + API | `Mailboxes` resolves the mailbox names core's own `akka.conf` ships from two built-in tables (`BuiltInMailboxTypes`, `BuiltInMessageQueueSemantics`) instead of `Type.GetType`, so the trimmer and the Native AOT compiler can see which type is loaded. Two observable changes. (1) A built-in `mailbox-type` is constructed directly, so when its constructor rejects its config the `ArgumentException` from `Mailboxes.Lookup` carries that constructor's own exception as `InnerException` instead of the `TargetInvocationException` `Activator.CreateInstance` wrapped it in, and names the configured spelling (e.g. `Akka.Dispatch.BoundedMailbox, Akka`) where it previously named `Type.ToString()`; relatedly, the non-zero-push-timeout warning now runs outside that `try`, so an exception from a custom `IProducesPushTimeoutSemanticsMailbox.PushTimeout` getter propagates as itself rather than wrapped. (2) With the `Akka.DynamicTypeLoading` switch **off**, a key under `akka.actor.mailbox.requirements` that is neither built in nor resolvable now throws a `ConfigurationException` out of `ActorSystem.Create`, where the switch-on path still logs a warning and skips that one binding; an unknown `mailbox-type` or `mailbox-requirement` likewise throws a `ConfigurationException` naming the setting, the value and the switch, in place of the previous `ConfigurationException` / `TypeLoadException`. With the switch on - the default, and what every existing application gets - every other input resolves to the same mailbox as before. Separately, `MailboxType` and the `Type` parameters of `Mailboxes.HasRequiredType`, `Mailboxes.ProducesMessageQueue` and `Mailboxes.GetRequiredType` gain `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]` so that trimming keeps `IProducesMessageQueue<>` / `IRequiresMessageQueue<>` on subclasses. | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around a rejected `mailbox-capacity` / `mailbox-push-timeout-time` should catch the inner exception type instead, and code asserting on the exact `Cannot instantiate MailboxType ...` text should expect the configured spelling for the five built-in types. A trimmed or Native AOT application that turns the switch off must keep `mailbox-type`, `mailbox-requirement` and every key under `akka.actor.mailbox.requirements` at built-in values. The new attributes are visible only to trim analysis -- they change no signature a compiler binds to -- but a caller that has trim analysis enabled and passes an unannotated `Type` into those three methods now gets `IL2072` at its own call site: annotate the `Type` it flows from with `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]`, or pass a `typeof(...)`. | -| Planned | `aot/m1-a-feature-switch-and-first-tables` | `Akka` (core / actor system bootstrap) | Behavior | Adds the internal trimming / Native AOT feature switch `Akka.DynamicTypeLoading` (default **on**) and puts built-in lookup tables in front of the first three HOCON type-name sites: `akka.stdout-logger-class` and `akka.logger-formatter` in `Settings`, and `akka.scheduler.implementation` in `ActorSystemImpl.ConfigureScheduler`. Each site now checks a `BuiltIn*` table before falling back to `Type.GetType`. Each table carries three spellings of every name it knows -- the bare `Ns.T`, the `Ns.T, Akka` form, and `typeof(T).AssemblyQualifiedName` (the `Version=`/`Culture=`/`PublicKeyToken=` form that `Akka.Hosting`'s `LoggerConfigBuilder` writes into HOCON) -- because core's own `akka.conf` and the Hosting packages between them use all three. With the switch on, which is what every existing application gets, the same config resolves to the same type as before, with two narrow differences: (1) a built-in name is constructed directly, so a constructor that rejects its config (e.g. `akka.scheduler.tick-duration` below 10 ms, which `HashedWheelTimerScheduler` refuses) now surfaces its own `ArgumentOutOfRangeException` / `ConfigurationException` instead of the `TargetInvocationException` that `Activator.CreateInstance` wrapped it in; (2) each of the three values is trimmed once before it is used - for the table lookup, the reflection fallback and the exception message alike - so a bare name with trailing whitespace now resolves where `Type.GetType` returned `null`, uniformly for built-in and custom names (`Type.GetType` already tolerated leading whitespace, and trailing whitespace on an assembly-qualified name). An application that turns the switch off with `` gets only the names in the tables: anything else throws a `ConfigurationException` naming the setting, the value and the switch, where it previously threw `TypeLoadException` (scheduler) or `ArgumentException` (stdout logger, log formatter). | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around `ActorSystem.Create` to read a rejected `akka.scheduler.*` value should catch the inner exception type instead. A trimmed or Native AOT application that turns the switch off must leave these three settings at their built-in values. | +| Planned | `aot/m1-a-feature-switch-and-first-tables` | `Akka` (core / actor system bootstrap) | Behavior | Adds the internal trimming / Native AOT feature switch `Akka.DynamicTypeLoading` (default **on**) and puts built-in lookup tables in front of the first three HOCON type-name sites: `akka.stdout-logger-class` and `akka.logger-formatter` in `Settings`, and `akka.scheduler.implementation` in `ActorSystemImpl.ConfigureScheduler`. Each site now checks a `BuiltIn*` table before falling back to `Type.GetType`. Each table carries two spellings of every name it knows -- the bare `Ns.T` and the `Ns.T, Akka` form, which is what core's own `akka.conf` uses -- and the lookup first runs the configured value through the new internal `Akka.Util.TypeExtensions.StripAssemblyIdentity`, which removes the `Version=`, `Culture=`, `PublicKeyToken=`, `ProcessorArchitecture=`, `Retargetable=` and `ContentType=` components. With the switch on, which is what every existing application gets, the same config resolves to the same type as before, with two narrow differences: (1) a built-in name is constructed directly, so a constructor that rejects its config (e.g. `akka.scheduler.tick-duration` below 10 ms, which `HashedWheelTimerScheduler` refuses) now surfaces its own `ArgumentOutOfRangeException` / `ConfigurationException` instead of the `TargetInvocationException` that `Activator.CreateInstance` wrapped it in; (2) a built-in type name written with a full assembly-qualified name of a different Akka version (what `Akka.Hosting` writes into HOCON, carried across an upgrade) now resolves where it previously threw. `Type.GetType` rejects the version mismatch, so on `dev` such a value fails in both switch states -- measured: `ArgumentException("Could not load type of ...")` for `akka.logger-formatter`/`akka.stdout-logger-class`, and `FileNotFoundException` for `akka.scheduler.implementation`, whose `Type.GetType` call passes `throwOnError: true`. Custom (non-built-in) names are unchanged, since the reflection fallback still receives the raw value. An application that turns the switch off with `` gets only the names in the tables: anything else throws a `ConfigurationException` naming the setting, the value and the switch, where it previously threw `TypeLoadException` (scheduler) or `ArgumentException` (stdout logger, log formatter). | None at the default -- the switch is on unless a project file explicitly turns it off. Code that caught `TargetInvocationException` around `ActorSystem.Create` to read a rejected `akka.scheduler.*` value should catch the inner exception type instead. A trimmed or Native AOT application that turns the switch off must leave these three settings at their built-in values. | | Planned | `fix/aot-provider-constant-type-names` | `Akka` (core / actor system bootstrap) | Behavior | `ActorSystemImpl` now resolves the three built-in actor ref providers from the compile-time constant type names on `ProviderSelection`, with the `Type.GetType` call inlined at each site, so the trimmer and the Native AOT compiler can see which type is loaded and keep it. Two observable consequences: (1) `ProviderSelection.GetProvider` now maps the local provider's type name -- both the bare `"Akka.Actor.LocalActorRefProvider"` that ships in `akka.conf` and the assembly-qualified `"Akka.Actor.LocalActorRefProvider, Akka"` -- onto `ProviderSelection.Local` instead of letting it fall through to `Custom`, so `Settings.ProviderClass` on a default boot now reports `"Akka.Actor.LocalActorRefProvider, Akka"` where it previously reported the bare `"Akka.Actor.LocalActorRefProvider"`. (2) `Settings` only validates `ProviderClass` through `Type.GetType` when the selection is `ProviderSelection.Custom`; the built-in three are validated where they are constructed, and a missing `Akka.Remote` / `Akka.Cluster` reference now throws `ConfigurationException("akka.actor.provider = cluster, but Akka.Cluster is not referenced by this application.")` from `ActorSystemImpl` rather than the generic "not a valid type name" message from `Settings`. Which provider gets constructed for any given config is unchanged. | None for normal use. Code that compares `Settings.ProviderClass` against the bare string `"Akka.Actor.LocalActorRefProvider"` should compare against `ProviderSelection.Local.Instance.Fqn`, or test `Settings.ProviderSelectionType is ProviderSelection.Local`. Code that asserted on the exact text of the misconfiguration exception for `provider = remote` / `provider = cluster` should expect the new message. | | Planned | `feature/hosting-import-dev` (forward-port of [#8591](https://github.com/akkadotnet/akka.net/pull/8591)) | `Akka.Hosting`, `Akka.Remote.Hosting`, `Akka.Cluster.Hosting`, `Akka.Persistence.Hosting`, `Akka.Hosting.TestKit`, `Akka.Hosting.TestKit.Xunit2` | API | The Akka.Hosting packages now ship from this repository at the Akka.NET version. On 1.6 they target `net10.0` only (previously `netstandard2.0;net6.0`), matching the rest of Akka.NET 1.6. `Microsoft.Extensions.*` floor moves to 10.0.0 for `Akka`, `Akka.DependencyInjection` and the Hosting packages. | Reference `Akka.Hosting.*` at the same version as `Akka.*`. Applications must target `net10.0` or later, as for every other Akka.NET 1.6 package. | | Planned | `fix/testkit-xunit-async-dispose-chain` | `Akka.TestKit.Xunit` | API + Behavior | `Akka.TestKit.Xunit.TestKit` now also implements `Xunit.IAsyncLifetime`, adding `virtual ValueTask InitializeAsync()` and `virtual ValueTask DisposeAsync()`. This closes #8191: xUnit v3 tears a test class instance down via `IAsyncDisposable.DisposeAsync()` in preference to `IDisposable.Dispose()` whenever a type implements both, so a derived spec that declared its own `IAsyncLifetime.DisposeAsync()` (to run its own async teardown) short-circuited `TestKit.Dispose(bool)` entirely and silently leaked its `ActorSystem` -- DotNetty threads, a bound socket, and a `PeriodicTimer`-driven scheduler included. `TestKit.DisposeAsync()` now runs the synchronous dispose chain (`AfterAll()` etc.) and then shuts the system down with the non-blocking `ShutdownAsync()` instead of the blocking `Shutdown()`. | A derived class that already declares its own `InitializeAsync`/`DisposeAsync` (whether via `IAsyncLifetime` directly or by re-declaring the method) now hides a virtual member of the same name and gets `CS0114`, which is an error under this repo's own `-warnaserror` build. Add `override` to each and chain to the base (`await base.InitializeAsync();` / `await base.DisposeAsync();`), and drop the redundant `IAsyncLifetime` interface declaration and any no-op `DisposeAsync` that only returned a completed `ValueTask`. | diff --git a/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs b/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs index 2712154f2a1..486654208bd 100644 --- a/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs +++ b/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs @@ -161,7 +161,7 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => _output.WriteLine(exception.Message); exception.Message.Should().Contain("custom-mailbox.mailbox-type"); exception.Message.Should().Contain(CustomMailboxTypeName); - exception.Message.Should().Contain(DynamicTypeLoadingCollection.Name); + exception.Message.Should().Contain(AkkaFeaturesSpec.SwitchName); } finally { @@ -187,7 +187,7 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, () => _output.WriteLine(exception.Message); exception.Message.Should().Contain("akka.actor.mailbox.requirements"); exception.Message.Should().Contain("Some.Unknown.Interface"); - exception.Message.Should().Contain(DynamicTypeLoadingCollection.Name); + exception.Message.Should().Contain(AkkaFeaturesSpec.SwitchName); return Task.CompletedTask; }); } diff --git a/src/core/Akka/Dispatch/Mailboxes.cs b/src/core/Akka/Dispatch/Mailboxes.cs index c85503faf58..c07156ba8ce 100644 --- a/src/core/Akka/Dispatch/Mailboxes.cs +++ b/src/core/Akka/Dispatch/Mailboxes.cs @@ -391,7 +391,7 @@ private Type GetProducedMessageQueueType(MailboxType mailboxType) private Type GetMailboxRequirement(Config config) { var mailboxRequirement = config.GetString("mailbox-requirement", null); - if (string.IsNullOrEmpty(mailboxRequirement) || mailboxRequirement.Equals(NoMailboxRequirement)) + if (mailboxRequirement == null || mailboxRequirement.Equals(NoMailboxRequirement)) return typeof(IMessageQueue); if (BuiltInMessageQueueSemantics.TryGetValue(