From 9f06d3628189267c70054e8fa3c9bc127db68d7e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 24 Sep 2026 16:59:41 +0000 Subject: [PATCH] Collapse every BuiltIn* type-name table to one key, widen matching to Type.GetType's rules Every BuiltIn* table (loggers, schedulers, mailboxes, message-queue semantics, routers, supervisor-strategy configurators, serializers, serialization bindings, the DNS provider) carried two keys per type -- the bare name and "Ns.T, Akka" -- and matched only those two spellings after stripping the assembly identity. Type.GetType accepts more: any assembly identity, an optional space after the comma, and a case-insensitive assembly name. Add TypeExtensions.TrySplitTypeName and TypeExtensions.ToBuiltInAkkaTypeName and route every table lookup through the latter, so each table now carries a single bare-name key and accepts the same spellings Type.GetType did. SerializerIdentifierHelper keeps its own two-pass, any-assembly matching but now shares TrySplitTypeName instead of its private comma-splitting code. Record the widened matching as a v1.6 breaking change and note where the A row's two-key description no longer applies. --- BREAKING_CHANGES_V1.6.md | 5 +- .../Dispatch/MailboxFeatureSwitchSpec.cs | 27 ++++++ .../Akka.Tests/IO/DnsProviderConfigSpec.cs | 2 + .../BuiltInSerializerDefaultsSpec.cs | 17 ++++ src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs | 44 +++++++++ .../Util/DynamicTypeLoadingConfigSpec.cs | 30 ++++++ src/core/Akka/Actor/Deployer.cs | 21 +--- .../Akka/Actor/Internal/ActorSystemImpl.cs | 15 +-- src/core/Akka/Actor/Settings.cs | 31 ++---- src/core/Akka/Actor/SupervisorStrategy.cs | 22 +---- src/core/Akka/Dispatch/Mailboxes.cs | 44 ++------- src/core/Akka/Event/LoggingBus.cs | 54 ++--------- src/core/Akka/IO/Dns.cs | 9 +- src/core/Akka/Serialization/Serialization.cs | 82 ++++++++-------- src/core/Akka/Serialization/Serializer.cs | 60 ++++-------- src/core/Akka/Util/AkkaFeatures.cs | 14 +-- src/core/Akka/Util/TypeExtensions.cs | 95 +++++++++++++++++++ 17 files changed, 328 insertions(+), 244 deletions(-) diff --git a/BREAKING_CHANGES_V1.6.md b/BREAKING_CHANGES_V1.6.md index 589db56f9cd..c7eae810d98 100644 --- a/BREAKING_CHANGES_V1.6.md +++ b/BREAKING_CHANGES_V1.6.md @@ -40,16 +40,17 @@ of `Behavior`, `Wire`, `API` (combine with `+`). | Status | PR / Branch | Component | Type | Change | Migration | |--------|-------------|-----------|------|--------|-----------| +| Planned | `aot/m1-g-one-builtin-name-rule` | `Akka` (core) | Behavior | Every `BuiltIn*` type-name table (loggers, schedulers, mailboxes, message-queue semantics, routers, supervisor-strategy configurators, serializers and serialization bindings, the DNS provider) now carries one key per type instead of two, and the lookup normalizes the configured value through a new `TypeExtensions.ToBuiltInAkkaTypeName` that accepts every spelling `Type.GetType` itself accepted for an Akka.NET type -- any assembly identity, an optional space after the comma, and any casing of the `Akka` assembly name. With `Akka.DynamicTypeLoading` off, a value like `"Akka.Dispatch.BoundedMailbox,akka"` now resolves where it previously threw a `ConfigurationException`; with the switch on, behavior is unchanged. | None. | | Planned | `aot/m1-f-canary-ci-and-targets` | `Akka` (package MSBuild logic) | Behavior | The `Akka` package now ships `buildTransitive/Akka.targets`, which adds `` whenever the consuming project sets `PublishAot=true` or `PublishTrimmed=true`. Consumers publishing for Native AOT or trimming therefore get the switch **off** automatically, without knowing it exists: core resolves the type names its own `akka.conf` uses from the built-in tables, the trimmer removes every reflection fallback behind the switch, and any other HOCON type name throws a `ConfigurationException` naming the setting instead of failing obscurely. Ordinary untrimmed builds are untouched -- the condition does not match, no item is added, and the switch keeps its default of on. The change is observable for **any** trimmed or Native AOT publish. The affected population is applications that hand-rooted a HOCON-named type -- with `TrimmerRootAssembly`, `TrimmerRootDescriptor` or `DynamicDependency` -- and relied on `Type.GetType` then finding it; that worked under ILC as well as under ILLink (the rooted type survives and is resolvable at runtime; all you got was an `IL2057` warning at build time), and it now throws. | None for an untrimmed application. A trimmed-but-not-AOT application that relied on hand-rooting a custom logger, mailbox, dispatcher, router, serializer, scheduler or provider must either move that type to the corresponding `Setup` API, or opt back out by declaring the option itself -- `` -- which wins over the package's value. | | Planned | `aot/io-dns-and-buffer-pool-not-configurable` | `Akka.IO` | Behavior | UDP no longer pools byte buffers. `UdpExt` and `UdpConnectedExt` always construct `Akka.IO.Buffers.DisabledBufferPool` directly, reading only its `buffer-size` key from `akka.io.udp.disabled-buffer-pool` / `akka.io.udp-connected.disabled-buffer-pool` (each section's `class` key is gone); the `buffer-pool` key that used to point at either section and the `direct-buffer-pool` section itself are both gone from the shipped `akka.conf`, and the internal `Akka.IO.Buffers.DirectBufferPool` type is deleted along with the reflection (`Type.GetType` + `Activator.CreateInstance`) that used to build whichever pool `class` named. The public `IBufferPool`, `BufferPoolInfo` and `BufferPoolAllocationException` types are untouched, so nothing that only depends on the interface breaks. `akka.io.tcp.buffer-pool` / `direct-buffer-pool` / `disabled-buffer-pool` are also removed from `akka.conf` -- they were dead: `TcpExt` never read them (`TcpSettings.BufferPoolConfigPath` has been `[Obsolete("This property is unused")]` since before this change). | If you pointed `akka.io.udp(-connected).buffer-pool` at `direct-buffer-pool` with a larger `buffer-size` than the default 512 (or supplied your own `buffer-pool` section), move that `buffer-size` value to `disabled-buffer-pool.buffer-size` -- otherwise it silently reverts to 512 and UDP datagrams larger than that are truncated. Pooling itself cannot be re-enabled; open an issue at https://github.com/akkadotnet/akka.net/issues if you depended on it for GC pressure. Delete any `akka.io.tcp.*buffer-pool*` override from your HOCON; it already did nothing. | | Planned | `aot/io-dns-and-buffer-pool-not-configurable` | `Akka.IO` | Behavior | `DnsExt` resolves `akka.io.dns..provider-object` through a built-in table before falling back to reflection: the built-in `InetAddressDnsProvider` is constructed directly (as are `SimpleDnsManager` and `InetAddressDnsResolver`, its manager/resolver pair), in every spelling the other built-in tables accept. `provider-object` keeps its meaning and stays configurable -- a custom provider still loads through `Type.GetType` + `Activator.CreateInstance` while `Akka.DynamicTypeLoading` is on, exactly as before. With the switch **off**, a `provider-object` that does not name the built-in provider now throws `ConfigurationException` from `AkkaFeatures.NotBuiltIn` naming the setting, the value and the switch, instead of being resolved by reflection. | None at the default -- the switch is on unless a project file explicitly turns it off. A trimmed or Native AOT application that turns the switch off must leave `provider-object` unset or pointing at `Akka.IO.InetAddressDnsProvider`. | | Planned | `aot/m1-e-routers-guardian-dispatchers` | `Akka` (core / routers, supervision, dispatchers) | Behavior | Four more HOCON type-name sites gain a built-in table and/or put their `Type.GetType` fallback behind the `Akka.DynamicTypeLoading` switch: `akka.actor.router.type-mapping`, `akka.actor.guardian-supervisor-strategy` (and Akka.Persistence's per-plugin `supervisor-strategy`), a dispatcher's `type`, and a dispatcher's `executor`. Both tables use the same normalized lookup as the other built-in tables (see the A row). With the switch on the same config resolves to the same type as before. With it off, a name that is not built in throws `ConfigurationException` from `AkkaFeatures.NotBuiltIn` naming the setting, the value and the switch. | None at the default -- the switch is on unless a project file explicitly turns it off. With it off, these four settings must keep the values core's `akka.conf` ships; Akka.Cluster.Metrics routers (`cluster-metrics-adaptive-pool`/`-group`) and Akka.TestKit's calling-thread dispatcher are named by type and need the switch on. | | Planned | `aot/m1-e-routers-guardian-dispatchers` | `Akka` (core / `Props.Create` expression parsing) | Behavior | `ExpressionBasedParser`'s fallback for a constructor argument it cannot read statically now compiles a typed `Expression.Lambda>` and invokes it directly instead of the non-generic overload plus `Delegate.DynamicInvoke`. The value is identical; an argument expression that throws while being evaluated now surfaces its own exception rather than a `TargetInvocationException`. | Code that read `ArgumentException.InnerException` after a failed `Props.Create(() => new MyActor(...))` should unwrap one `TargetInvocationException` less. | | Planned | `aot/m1-d-serializer-defaults` | `Akka.Serialization` | Behavior | `SerializerIdentifierHelper.GetSerializerIdentifierFromConfig` matches `akka.actor.serialization-identifiers` keys against the name of the `Type` in hand instead of calling `Type.GetType(key, throwOnError: true)` on every key. Fixes one unloadable key in the block taking `ActorSystem.Create` down with it; narrows matching to string comparison, and widens it by ignoring the assembly identity and by letting a bare `FullName` key match a type in any assembly. | Spell keys `"Namespace.Serializer, AssemblyName"`, optionally with the `Version`/`Culture`/`PublicKeyToken` tail. Prefer assembly-qualified keys: two serializers with the same `FullName` in different assemblies would now share whichever bare key matches first, which corrupts the wire. | -| Planned | `aot/m1-d-serializer-defaults` | `Akka.Serialization` | Behavior | `Serialization..ctor` registers the `bytes`/`json` serializers and the `System.Byte[]`/`System.Object` bindings from built-in tables instead of `Type.GetType`, falling back to reflection while `Akka.DynamicTypeLoading` is on and throwing a `ConfigurationException` when it is off. With the switch off core does not register `json` at all, so a type with no binding of its own has no fallback serializer. A built-in serializer is also constructed directly now. | None at the default. An application that turns `Akka.DynamicTypeLoading` off must register a serializer for `System.Object` (or per message type) through a `SerializationSetup`; pointing the existing `"System.Object"` binding at that alias works. A `SerializationSetup` also covers the HOCON rows it takes over, such as a module's `reference.conf` rows: a serializer row for an alias the Setup registers is skipped (its `serialization-settings` block goes unused), and a binding row resolves if it names one of the Setup's `UseFor` types by full name. Any other row core can't resolve still throws. Code catching `TargetInvocationException` around a serializer constructor should catch the inner exception type. | +| Planned | `aot/m1-d-serializer-defaults` | `Akka.Serialization` | Behavior | `Serialization..ctor` registers the `bytes`/`json` serializers and the `System.Byte[]`/`System.Object` bindings from built-in tables instead of `Type.GetType`, falling back to reflection while `Akka.DynamicTypeLoading` is on and throwing a `ConfigurationException` when it is off. With the switch off core does not register `json` at all, so a type with no binding of its own has no fallback serializer. A built-in serializer is also constructed directly now. | None at the default. An application that turns `Akka.DynamicTypeLoading` off must register a serializer for `System.Object` (or per message type) through a `SerializationSetup`; pointing the existing `"System.Object"` binding at that alias works. A `SerializationSetup` also covers the HOCON rows it takes over, such as a module's `reference.conf` rows: a serializer row for an alias the Setup registers is skipped (its `serialization-settings` block goes unused), and a binding row resolves if it names one of the Setup's `UseFor` types by full name, with its assembly half absent or equal to the type's assembly simple name (case-insensitive). Any other row core can't resolve still throws. Code catching `TargetInvocationException` around a serializer constructor should catch the inner exception type. | | Planned | `aot/m1-c-props-and-loggers` | `Akka` (core / `Akka.Actor.Props`, `Akka.Event.LoggingBus`), `Akka.DependencyInjection` | Behavior + API | Two changes. (1) The whole `Props` actor-type flow now carries one trimming contract, `[DynamicallyAccessedMembers(PublicConstructors \| Interfaces)]`, so the trimmer keeps the constructor `Activator` calls and the `IRequiresMessageQueue<>` interfaces the mailbox requirement check reads off the actor type: the `Type`-taking `Props` constructors, `Props.Type`, `Props.Create(Type, params object[])`, all three `Props.Create` overloads, `Props.CreateBy`, `IIndirectActorProducer.ActorType`, `ActorRefFactoryExtensions.ActorOf`, `ExtendedActorSystem.SystemActorOf` (and the `ActorSystemImpl` override), plus -- in `Akka.DependencyInjection` -- the three `IDependencyResolver.Props` members and their implementations on `DependencyResolver`, `ServiceProvider` and `ServiceProviderDependencyResolver`, and the internal `ServiceProviderActorProducer`. Separately, `ActorSystemWithExtensions.WithExtension(ActorSystem, Type)` and `WithExtension` gain `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]` -- the narrower contract, because those take an `IExtensionId`, not an actor type, and call the parameterless `Activator.CreateInstance`. No runtime behavior changes; the attributes are visible only to trim analysis and change no signature a compiler binds to. (2) `LoggingBus.StartDefaultLoggers` resolves `akka.loggers` from new built-in name tables (all three loggers Akka.dll ships -- `Akka.Event.DefaultLogger`, `Akka.Event.StandardOutLogger` and `Akka.Event.TraceLogger` -- each under three spellings: the bare name core's own `akka.conf` ships, the short assembly-qualified name that `TraceLogger`'s own documentation tells users to write, and the full versioned assembly-qualified name Akka.Hosting writes) before falling back to `Type.GetType`, and that fallback only runs while dynamic type loading is on. With the switch on, which is what every existing application gets, the same config resolves to the same logger as before, for every input. With the switch off, only the names in the table resolve: any other logger throws a `ConfigurationException` naming `akka.loggers`, the value and the switch, where it previously threw `ConfigurationException` "Logger specified in config cannot be found". | None at the default -- the switch is on unless a project file explicitly turns it off. A trimmed or Native AOT application that turns the switch off must leave `akka.loggers` at the built-in loggers. The new attributes need no source change, and nothing warns unless a project sets `EnableTrimAnalyzer` / `IsTrimmable` / `IsAotCompatible` -- no project in this repository does yet. Once one does: passing an unannotated `Type` into any of these members reports `IL2072` when it came from a method return or a field, and `IL2067` when it came from one of the caller's own parameters; an unannotated generic argument reports `IL2091`. Fix either by annotating the `Type` or type parameter it flows from with `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors \| DynamicallyAccessedMemberTypes.Interfaces)]`, or by passing a `typeof(...)`. Because the attribute is part of the contract, an *implementor* has to repeat it verbatim rather than flow it: a class implementing `IIndirectActorProducer.ActorType` or any of the three `IDependencyResolver.Props` members reports `IL2092` / `IL2093` (parameter and return annotation mismatch) or `IL2095` (type-parameter mismatch) until it carries the identical attribute, and so does an override of `ExtendedActorSystem.SystemActorOf`. | | 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 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-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 originally carried 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 (the `aot/m1-g-one-builtin-name-rule` row collapses this to one key per type) -- 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 486654208bd..9bfcd3fa375 100644 --- a/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs +++ b/src/core/Akka.Tests/Dispatch/MailboxFeatureSwitchSpec.cs @@ -122,6 +122,33 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => }); } + /// + /// Pins the widened matching rule the single-key table relies on: no space after the comma and a + /// lower-cased assembly name are both spellings itself accepted, + /// and now TypeExtensions.ToBuiltInAkkaTypeName accepts them too. + /// + [Fact(DisplayName = "Mailboxes should resolve a built-in mailbox-type spelled without a comma space and with a lower-cased assembly name when dynamic type loading is off")] + public async Task Should_resolve_a_loosely_spelled_mailbox_type_When_dynamic_type_loading_is_disabled() + { + var config = ConfigurationFactory.ParseString(@" + loose-mailbox { + mailbox-type = ""Akka.Dispatch.BoundedDequeBasedMailbox,akka"" + }"); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => + { + var system = ActorSystem.Create("loose-mailbox-off", config); + try + { + system.Mailboxes.Lookup("loose-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. diff --git a/src/core/Akka.Tests/IO/DnsProviderConfigSpec.cs b/src/core/Akka.Tests/IO/DnsProviderConfigSpec.cs index e271b8f92f2..42baf30c16f 100644 --- a/src/core/Akka.Tests/IO/DnsProviderConfigSpec.cs +++ b/src/core/Akka.Tests/IO/DnsProviderConfigSpec.cs @@ -75,6 +75,8 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => [Theory(DisplayName = "DnsExt should resolve every spelling of the built-in provider name when dynamic type loading is off")] [InlineData("Akka.IO.InetAddressDnsProvider")] [InlineData("Akka.IO.InetAddressDnsProvider, Akka")] + [InlineData("Akka.IO.InetAddressDnsProvider,Akka")] + [InlineData("Akka.IO.InetAddressDnsProvider, AKKA")] [InlineData("Akka.IO.InetAddressDnsProvider, Akka, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null")] public async Task Should_resolve_every_spelling_of_the_built_in_provider_When_dynamic_type_loading_is_disabled(string providerObjectName) { diff --git a/src/core/Akka.Tests/Serialization/BuiltInSerializerDefaultsSpec.cs b/src/core/Akka.Tests/Serialization/BuiltInSerializerDefaultsSpec.cs index 76497a2a71c..18611c91854 100644 --- a/src/core/Akka.Tests/Serialization/BuiltInSerializerDefaultsSpec.cs +++ b/src/core/Akka.Tests/Serialization/BuiltInSerializerDefaultsSpec.cs @@ -94,6 +94,7 @@ private static Config ModuleStyleConfig(string boundTypeName) => ConfigurationFa { "Akka.Tests.Serialization.BuiltInSerializerDefaultsSpec+SomePoco", "Akka.Tests.Serialization.BuiltInSerializerDefaultsSpec+SomePoco, Akka.Tests", + "Akka.Tests.Serialization.BuiltInSerializerDefaultsSpec+SomePoco ,akka.tests", "Akka.Tests.Serialization.BuiltInSerializerDefaultsSpec+SomePoco, Akka.Tests, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null" }; @@ -292,6 +293,22 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, })); } + [Fact(DisplayName = "Serialization should reject a binding row that names a SerializationSetup type under the wrong assembly when dynamic type loading is off")] + public async Task Should_throw_ConfigurationException_When_a_binding_row_names_the_wrong_assembly_and_dynamic_type_loading_is_disabled() + { + const string boundTypeName = "Akka.Tests.Serialization.BuiltInSerializerDefaultsSpec+SomePoco, Some.Other.Assembly"; + var setup = PocoSerializerSetup("poco", ModuleStyleConfig(boundTypeName), typeof(SomePoco)); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, () => + { + var exception = Assert.Throws( + () => ActorSystem.Create("setup-wrong-assembly-off", setup)); + + exception.Message.Should().Contain(boundTypeName); + return Task.CompletedTask; + }); + } + /// /// The serializer-row skip keys on the alias: a Setup that binds the type under an alias of its own /// does not excuse a HOCON serializer row core cannot build. diff --git a/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs b/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs index 899f301a178..44fa6044185 100644 --- a/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs +++ b/src/core/Akka.Tests/Util/AkkaFeaturesSpec.cs @@ -104,6 +104,50 @@ public void Should_strip_assembly_identity_From_a_qualified_type_name(string typ Akka.Util.TypeExtensions.StripAssemblyIdentity(typeName).Should().Be(expected); } + [Theory(DisplayName = "TypeExtensions.TrySplitTypeName should split a type name at the top-level comma")] + [InlineData("Akka.Event.SemanticLogMessageFormatter", "Akka.Event.SemanticLogMessageFormatter", null)] + [InlineData("Akka.Event.SemanticLogMessageFormatter, Akka", "Akka.Event.SemanticLogMessageFormatter", "Akka")] + [InlineData("Akka.Event.SemanticLogMessageFormatter,Akka", "Akka.Event.SemanticLogMessageFormatter", "Akka")] + [InlineData("Akka.Event.SemanticLogMessageFormatter, akka", "Akka.Event.SemanticLogMessageFormatter", "akka")] + [InlineData( + "Akka.Event.SemanticLogMessageFormatter, Akka, Version=1.5.60.0, Culture=neutral, PublicKeyToken=null", + "Akka.Event.SemanticLogMessageFormatter", "Akka")] + [InlineData( + "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Int32, mscorlib]], mscorlib", + "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Int32, mscorlib]]", "mscorlib")] + public void Should_split_a_type_name_at_the_top_level_comma(string typeName, string expectedName, string? expectedAssembly) + { + Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out var name, out var assembly).Should().BeTrue(); + name.Should().Be(expectedName); + assembly.Should().Be(expectedAssembly); + } + + [Theory(DisplayName = "TypeExtensions.TrySplitTypeName should reject a null, empty or whitespace-only type name")] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Should_reject_an_empty_type_name(string? typeName) + { + Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out _, out _).Should().BeFalse(); + } + + [Theory(DisplayName = "TypeExtensions.ToBuiltInAkkaTypeName should accept the bare name and every spelling of the Akka assembly")] + [InlineData("Akka.Event.SemanticLogMessageFormatter", "Akka.Event.SemanticLogMessageFormatter")] + [InlineData("Akka.Event.SemanticLogMessageFormatter, Akka", "Akka.Event.SemanticLogMessageFormatter")] + [InlineData("Akka.Event.SemanticLogMessageFormatter,Akka", "Akka.Event.SemanticLogMessageFormatter")] + [InlineData("Akka.Event.SemanticLogMessageFormatter, akka", "Akka.Event.SemanticLogMessageFormatter")] + [InlineData( + "Akka.Event.SemanticLogMessageFormatter, Akka, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null", + "Akka.Event.SemanticLogMessageFormatter")] + [InlineData("Akka.Event.SemanticLogMessageFormatter, Contoso", null)] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + public void Should_normalize_or_reject_a_type_name_When_computing_the_built_in_Akka_type_name(string? typeName, string? expected) + { + Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(typeName).Should().Be(expected); + } + /// /// The explicit switch-ON regression guard: the reflection fallback must still resolve a type that no /// BuiltIn* table knows about. Uses the log formatter rather than the scheduler because the diff --git a/src/core/Akka.Tests/Util/DynamicTypeLoadingConfigSpec.cs b/src/core/Akka.Tests/Util/DynamicTypeLoadingConfigSpec.cs index ef10c5c48b6..17cd09cc510 100644 --- a/src/core/Akka.Tests/Util/DynamicTypeLoadingConfigSpec.cs +++ b/src/core/Akka.Tests/Util/DynamicTypeLoadingConfigSpec.cs @@ -151,6 +151,36 @@ await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => }); } + /// + /// Pins the widened matching rule the single-key table relies on: no space after the comma is a + /// spelling itself accepted, and the table now accepts it too. + /// + [Fact(DisplayName = "Deployer should resolve a router type-mapping spelled without a comma space when dynamic type loading is off")] + public async Task Should_resolve_a_loosely_spelled_router_type_mapping_When_dynamic_type_loading_is_disabled() + { + var config = ConfigurationFactory.ParseString(@" + akka.actor.router.type-mapping.loose-round-robin-pool = ""Akka.Routing.RoundRobinPool,Akka"" + akka.actor.deployment { + /loose { + router = loose-round-robin-pool + } + }"); + + await AkkaFeaturesSpec.WithDynamicTypeLoading(false, async () => + { + var system = ActorSystem.Create("loose-router-off", config); + try + { + var deploy = ((ActorSystemImpl)system).Provider.Deployer.Lookup(new[] { "loose" }); + deploy.RouterConfig.Should().BeOfType(); + } + finally + { + await system.Terminate(); + } + }); + } + [Fact(DisplayName = "Deployer should reject a router type-mapping that is not built in when dynamic type loading is off")] public async Task Should_throw_ConfigurationException_When_the_router_is_not_built_in_and_dynamic_type_loading_is_disabled() { diff --git a/src/core/Akka/Actor/Deployer.cs b/src/core/Akka/Actor/Deployer.cs index 6b0bd530bb3..ed26f85858b 100644 --- a/src/core/Akka/Actor/Deployer.cs +++ b/src/core/Akka/Actor/Deployer.cs @@ -38,14 +38,7 @@ public class Deployer /// Builds : the 13 routers akka.actor.router.type-mapping /// maps onto types inside Akka.dll and that can actually reach this table. /// - /// Two spellings per router, 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 - /// first, so a full - /// - 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. + /// Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. /// private static Dictionary> BuildBuiltInRouterConfigs() { @@ -72,15 +65,11 @@ private static Dictionary> BuildBuiltInRouter return builtIn; // typeof(TRouter) is what keeps this trimmer-safe: the trimmer sees the type, keeps it, and hands - // us its own names, so no spelling can drift out of step with the type it maps to. + // us its own name, so the key can't drift out of step with the type it maps to. void Add(Func factory) where TRouter : RouterConfig { - var routerType = typeof(TRouter); - // "Akka.Routing.RoundRobinPool" - builtIn[routerType.FullName] = factory; - // "Akka.Routing.RoundRobinPool, Akka" - builtIn[$"{routerType.FullName}, {routerType.Assembly.GetName().Name}"] = factory; + builtIn[typeof(TRouter).FullName] = factory; } } @@ -213,8 +202,8 @@ private RouterConfig CreateRouterConfig(string routerTypeAlias, Config deploymen throw new ConfigurationException(message); } - if (BuiltInRouterConfigs.TryGetValue( - Akka.Util.TypeExtensions.StripAssemblyIdentity(routerTypeName), out var routerFactory)) + if (Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(routerTypeName) is { } builtInRouterTypeName && + BuiltInRouterConfigs.TryGetValue(builtInRouterTypeName, out var routerFactory)) return routerFactory(deployment); if (!AkkaFeatures.IsDynamicTypeLoadingSupported) diff --git a/src/core/Akka/Actor/Internal/ActorSystemImpl.cs b/src/core/Akka/Actor/Internal/ActorSystemImpl.cs index c3b7d13f075..ae820ba26ab 100644 --- a/src/core/Akka/Actor/Internal/ActorSystemImpl.cs +++ b/src/core/Akka/Actor/Internal/ActorSystemImpl.cs @@ -284,26 +284,19 @@ public override ActorSelection ActorSelection(string actorPath) // The akka.scheduler.implementation values that ship inside Akka.dll, constructed directly so that // neither the trimmer nor the Native AOT compiler has to see through a Type.GetType call. // - // Two spellings, 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. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. private static readonly Dictionary> BuiltInSchedulers = new(StringComparer.Ordinal) { - ["Akka.Actor.HashedWheelTimerScheduler"] = static (config, log) => new HashedWheelTimerScheduler(config, log), - ["Akka.Actor.HashedWheelTimerScheduler, Akka"] = static (config, log) => new HashedWheelTimerScheduler(config, log) + ["Akka.Actor.HashedWheelTimerScheduler"] = static (config, log) => new HashedWheelTimerScheduler(config, log) }; private void ConfigureScheduler() { var schedulerClass = _settings.SchedulerClass; // fully qualified: this file also imports System.Reflection, which has its own TypeExtensions - if (BuiltInSchedulers.TryGetValue( - Util.TypeExtensions.StripAssemblyIdentity(schedulerClass), out var factory)) + if (Util.TypeExtensions.ToBuiltInAkkaTypeName(schedulerClass) is { } builtInSchedulerName && + BuiltInSchedulers.TryGetValue(builtInSchedulerName, out var factory)) { _scheduler = factory(_settings.Config, Log); } diff --git a/src/core/Akka/Actor/Settings.cs b/src/core/Akka/Actor/Settings.cs index 139117a6d20..c51312894f3 100644 --- a/src/core/Akka/Actor/Settings.cs +++ b/src/core/Akka/Actor/Settings.cs @@ -30,37 +30,22 @@ public class Settings // The akka.stdout-logger-class 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. // - // akka.conf leaves this setting empty, so the table is only ever reached from user config. Two - // spellings, both deliberate: the bare name and the "Ns.T, Akka" form, which is what HOCON in the - // wild carries. 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. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. private static readonly Dictionary> BuiltInStdoutLoggers = new(StringComparer.Ordinal) { - ["Akka.Event.StandardOutLogger"] = static () => new StandardOutLogger(), - ["Akka.Event.StandardOutLogger, Akka"] = static () => new StandardOutLogger() + ["Akka.Event.StandardOutLogger"] = static () => new StandardOutLogger() }; // The akka.logger-formatter 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, both deliberate: akka.conf ships the "Ns.T, Akka" form and HOCON in the wild also - // carries the bare name. 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. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. private static readonly Dictionary> BuiltInLogMessageFormatters = new(StringComparer.Ordinal) { ["Akka.Event.DefaultLogMessageFormatter"] = static () => DefaultLogMessageFormatter.Instance, - ["Akka.Event.DefaultLogMessageFormatter, Akka"] = static () => DefaultLogMessageFormatter.Instance, - ["Akka.Event.SemanticLogMessageFormatter"] = static () => SemanticLogMessageFormatter.Instance, - ["Akka.Event.SemanticLogMessageFormatter, Akka"] = static () => SemanticLogMessageFormatter.Instance + ["Akka.Event.SemanticLogMessageFormatter"] = static () => SemanticLogMessageFormatter.Instance }; private readonly Config _userConfig; @@ -180,8 +165,8 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) { StdoutLogger = new StandardOutLogger(); } - else if (BuiltInStdoutLoggers.TryGetValue( - TypeExtensions.StripAssemblyIdentity(stdoutClassName), out var stdoutLoggerFactory)) + else if (TypeExtensions.ToBuiltInAkkaTypeName(stdoutClassName) is { } builtInStdoutLoggerName && + BuiltInStdoutLoggers.TryGetValue(builtInStdoutLoggerName, out var stdoutLoggerFactory)) { StdoutLogger = stdoutLoggerFactory(); } @@ -208,8 +193,8 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) { LogFormatter = DefaultLogMessageFormatter.Instance; } - else if (BuiltInLogMessageFormatters.TryGetValue( - TypeExtensions.StripAssemblyIdentity(loggerFormatterName), out var logFormatterFactory)) + else if (TypeExtensions.ToBuiltInAkkaTypeName(loggerFormatterName) is { } builtInLogFormatterName && + BuiltInLogMessageFormatters.TryGetValue(builtInLogFormatterName, out var logFormatterFactory)) { LogFormatter = logFormatterFactory(); } diff --git a/src/core/Akka/Actor/SupervisorStrategy.cs b/src/core/Akka/Actor/SupervisorStrategy.cs index 5b2ce15ca7e..b7cc96d43e6 100644 --- a/src/core/Akka/Actor/SupervisorStrategy.cs +++ b/src/core/Akka/Actor/SupervisorStrategy.cs @@ -1028,8 +1028,8 @@ internal static SupervisorStrategyConfigurator CreateConfigurator(string typeNam if (typeName == null) throw new ConfigurationException("Could not resolve SupervisorStrategyConfigurator. typeName is null"); - if (BuiltInConfigurators.TryGetValue( - Akka.Util.TypeExtensions.StripAssemblyIdentity(typeName), out var factory)) + if (Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(typeName) is { } builtInConfiguratorName && + BuiltInConfigurators.TryGetValue(builtInConfiguratorName, out var factory)) return factory(); if (!AkkaFeatures.IsDynamicTypeLoadingSupported) @@ -1044,15 +1044,7 @@ internal static SupervisorStrategyConfigurator CreateConfigurator(string typeNam /// /// Builds . /// - /// Two spellings per configurator, both deliberate: akka.conf and - /// persistence.conf ship the bare name and HOCON in the wild also carries the - /// Ns.T, Akka form. The lookup runs the configured value through - /// first, so a full - /// - 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. + /// Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. /// private static Dictionary> BuildBuiltInConfigurators() { @@ -1064,16 +1056,12 @@ private static Dictionary> BuildBui return builtIn; // typeof(TConfigurator) is what keeps this trimmer-safe: the trimmer sees the type, keeps it, and - // hands us its own names, so no spelling can drift out of step with the type it maps to. + // hands us its own name, so the key can't drift out of step with the type it maps to. void Add(Func factory) where TConfigurator : SupervisorStrategyConfigurator { - var configuratorType = typeof(TConfigurator); - // "Akka.Actor.DefaultSupervisorStrategy" - builtIn[configuratorType.FullName] = factory; - // "Akka.Actor.DefaultSupervisorStrategy, Akka" - builtIn[$"{configuratorType.FullName}, {configuratorType.Assembly.GetName().Name}"] = factory; + builtIn[typeof(TConfigurator).FullName] = factory; } } diff --git a/src/core/Akka/Dispatch/Mailboxes.cs b/src/core/Akka/Dispatch/Mailboxes.cs index c07156ba8ce..324a8e6cf56 100644 --- a/src/core/Akka/Dispatch/Mailboxes.cs +++ b/src/core/Akka/Dispatch/Mailboxes.cs @@ -49,56 +49,32 @@ public class Mailboxes // 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. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. 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) + ["Akka.Event.LoggerMailboxType"] = 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. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. 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) + ["Akka.Event.ILoggerMessageQueueSemantics"] = typeof(ILoggerMessageQueueSemantics) }; private Settings Settings => _system.Settings; @@ -125,8 +101,8 @@ public Mailboxes(ActorSystem system) var requirementName = kvp.Key; Type type; - if (BuiltInMessageQueueSemantics.TryGetValue( - Util.TypeExtensions.StripAssemblyIdentity(requirementName), out var builtIn)) + if (Util.TypeExtensions.ToBuiltInAkkaTypeName(requirementName) is { } builtInRequirementName && + BuiltInMessageQueueSemantics.TryGetValue(builtInRequirementName, out var builtIn)) { type = builtIn; } @@ -258,8 +234,8 @@ private MailboxType LookupConfigurator(string id) if (string.IsNullOrEmpty(mailboxTypeName)) throw new ConfigurationException($"The setting mailbox-type defined in [{id}] is empty"); - if (BuiltInMailboxTypes.TryGetValue( - Util.TypeExtensions.StripAssemblyIdentity(mailboxTypeName), out var builtIn)) + if (Util.TypeExtensions.ToBuiltInAkkaTypeName(mailboxTypeName) is { } builtInMailboxTypeName && + BuiltInMailboxTypes.TryGetValue(builtInMailboxTypeName, out var builtIn)) { try { @@ -394,8 +370,8 @@ private Type GetMailboxRequirement(Config config) if (mailboxRequirement == null || mailboxRequirement.Equals(NoMailboxRequirement)) return typeof(IMessageQueue); - if (BuiltInMessageQueueSemantics.TryGetValue( - Util.TypeExtensions.StripAssemblyIdentity(mailboxRequirement), out var requirementType)) + if (Util.TypeExtensions.ToBuiltInAkkaTypeName(mailboxRequirement) is { } builtInMailboxRequirementName && + BuiltInMessageQueueSemantics.TryGetValue(builtInMailboxRequirementName, out var requirementType)) return requirementType; if (AkkaFeatures.IsDynamicTypeLoadingSupported) diff --git a/src/core/Akka/Event/LoggingBus.cs b/src/core/Akka/Event/LoggingBus.cs index 7a394a1d0bc..a93a21f9183 100644 --- a/src/core/Akka/Event/LoggingBus.cs +++ b/src/core/Akka/Event/LoggingBus.cs @@ -39,39 +39,6 @@ public LoggerStartInfo(string loggerName, Type loggerType, IActorRef actorRef) public override string ToString() => _name; } - // The akka.loggers values that name one of the three loggers Akka.dll ships. GetBuiltInLoggerType - // matches these and hands back the type from a constant typeof, so the trimmer and the Native AOT - // compiler can see it without looking through Type.GetType. - // - // Two spellings per logger, both deliberate: akka.conf ships the bare "Akka.Event.DefaultLogger", and - // HOCON in the wild also carries the "Ns.T, Akka" form - which is what TraceLogger's own documentation - // tells users to write. 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 these tables 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 string[] BuiltInDefaultLoggerNames = - new[] - { - "Akka.Event.DefaultLogger", - "Akka.Event.DefaultLogger, Akka" - }; - - private static readonly string[] BuiltInStandardOutLoggerNames = - new[] - { - "Akka.Event.StandardOutLogger", - "Akka.Event.StandardOutLogger, Akka" - }; - - private static readonly string[] BuiltInTraceLoggerNames = - new[] - { - "Akka.Event.TraceLogger", - "Akka.Event.TraceLogger, Akka" - }; - private static readonly LogLevel[] AllLogLevels = Enum.GetValues(typeof(LogLevel)).Cast().ToArray(); private static int _loggerId; @@ -243,23 +210,20 @@ private void RemoveLogger(IActorRef logger) // A Dictionary would read better, but it cannot carry DynamicallyAccessedMembers on its // values: the type coming back out of TryGetValue arrives unannotated, and the trimmer then drops the // constructor Props needs - IL2067 here, MissingMethodException on the first log event at run time. - // Array.IndexOf over string[] compares ordinally, which is what these names want. + // Keyed by bare type name; TypeExtensions.ToBuiltInAkkaTypeName normalizes what HOCON carries. [return: DynamicallyAccessedMembers(Props.ActorTypeMembers)] private static Type GetBuiltInLoggerType(string loggerTypeName) { // Util. qualifies this: System.Reflection.TypeExtensions is in scope here too. - var name = Util.TypeExtensions.StripAssemblyIdentity(loggerTypeName); - - if (Array.IndexOf(BuiltInDefaultLoggerNames, name) >= 0) - return typeof(DefaultLogger); - - if (Array.IndexOf(BuiltInStandardOutLoggerNames, name) >= 0) - return typeof(StandardOutLogger); + var name = Util.TypeExtensions.ToBuiltInAkkaTypeName(loggerTypeName); - if (Array.IndexOf(BuiltInTraceLoggerNames, name) >= 0) - return typeof(TraceLogger); - - return null; + return name switch + { + "Akka.Event.DefaultLogger" => typeof(DefaultLogger), + "Akka.Event.StandardOutLogger" => typeof(StandardOutLogger), + "Akka.Event.TraceLogger" => typeof(TraceLogger), + _ => null + }; } [RequiresUnreferencedCode("Loads the [akka.loggers] type by name. The trimmer cannot tell which type that is, so it may have been trimmed away.")] diff --git a/src/core/Akka/IO/Dns.cs b/src/core/Akka/IO/Dns.cs index 39eba14caa1..fc06304aeb9 100644 --- a/src/core/Akka/IO/Dns.cs +++ b/src/core/Akka/IO/Dns.cs @@ -207,12 +207,11 @@ public override DnsExt CreateExtension(ExtendedActorSystem system) /// public class DnsExt : IOExtension { - /// The provider-object spellings that name the built-in . + /// The built-in provider-object names, keyed by bare type name (see ). private static readonly Dictionary> BuiltInDnsProviders = new(StringComparer.Ordinal) { - ["Akka.IO.InetAddressDnsProvider"] = static () => new InetAddressDnsProvider(), - ["Akka.IO.InetAddressDnsProvider, Akka"] = static () => new InetAddressDnsProvider() + ["Akka.IO.InetAddressDnsProvider"] = static () => new InetAddressDnsProvider() }; /// @@ -276,8 +275,8 @@ public DnsExt(ExtendedActorSystem system) /// Resolves ' provider-object into an . private static IDnsProvider ResolveProvider(DnsSettings settings) { - if (BuiltInDnsProviders.TryGetValue( - Akka.Util.TypeExtensions.StripAssemblyIdentity(settings.ProviderObjectName), out var providerFactory)) + if (Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(settings.ProviderObjectName) is { } builtInName && + BuiltInDnsProviders.TryGetValue(builtInName, out var providerFactory)) return providerFactory(); if (!AkkaFeatures.IsDynamicTypeLoadingSupported) diff --git a/src/core/Akka/Serialization/Serialization.cs b/src/core/Akka/Serialization/Serialization.cs index 4600835f80b..ce959bcdd0c 100644 --- a/src/core/Akka/Serialization/Serialization.cs +++ b/src/core/Akka/Serialization/Serialization.cs @@ -208,16 +208,10 @@ public static T WithTransport(ActorSystem system, Address address, Func ac /// /// /// - /// Both spellings of each name are deliberate, because both turn up in real HOCON: the bare - /// namespace-qualified name and the assembly-qualified name that core's own akka.conf ships. The - /// full versioned that Akka.Hosting writes needs no key of its - /// own, because the lookup runs the configured value through - /// first. That is deliberate in - /// preference to a third key built from typeof(T).AssemblyQualifiedName: not for AOT size, which - /// was measured at 16 bytes on the canary with no new trim warnings either way, but because stripping - /// matches every version of the name instead of only the version this build happens to carry. A - /// name that is missing here falls through to the reflection path, which is unavailable (and therefore - /// throws) once dynamic type loading is switched off. Do not collapse the spellings into one entry. + /// Keyed by bare type name; normalizes + /// what HOCON carries, including the full versioned that + /// Akka.Hosting writes. A name that is missing here falls through to the reflection path, which is + /// unavailable (and therefore throws) once dynamic type loading is switched off. /// /// /// A factory returns null when the serializer ships inside Akka.dll but cannot work under the @@ -228,9 +222,7 @@ public static T WithTransport(ActorSystem system, Address address, Func ac new(StringComparer.Ordinal) { ["Akka.Serialization.ByteArraySerializer"] = CreateByteArraySerializer, - ["Akka.Serialization.ByteArraySerializer, Akka"] = CreateByteArraySerializer, - ["Akka.Serialization.NewtonSoftJsonSerializer"] = CreateNewtonSoftJsonSerializer, - ["Akka.Serialization.NewtonSoftJsonSerializer, Akka"] = CreateNewtonSoftJsonSerializer + ["Akka.Serialization.NewtonSoftJsonSerializer"] = CreateNewtonSoftJsonSerializer }; /// @@ -240,16 +232,14 @@ public static T WithTransport(ActorSystem system, Address address, Func ac /// /// /// - /// Every spelling here is deliberate, because every one of them is a name - /// used to resolve: the bare namespace-qualified name that core's own akka.conf ships, and four - /// assembly-qualified spellings. These two are framework types rather than Akka types, so there is no - /// single qualified spelling to cover - mscorlib, System.Private.CoreLib, - /// System.Runtime and netstandard all resolve, and any of them can show up in HOCON that - /// was written against an older target framework. The versioned spellings need no keys of their own, - /// because the lookup runs the configured name through - /// first. A key that is missing here - /// falls through to the reflection path, which is unavailable (and therefore throws) once dynamic type - /// loading is switched off. Do not collapse the spellings into one entry. + /// Keyed by bare type name. These two are framework types rather than Akka types, so + /// does not apply here: the lookup instead + /// runs the configured name through and accepts + /// it when the assembly half is absent or is one of - + /// mscorlib, System.Private.CoreLib, System.Runtime or netstandard, matched + /// case-insensitively - which covers every runtime these two names can be written against. A key that + /// is missing here falls through to the reflection path, which is unavailable (and therefore throws) + /// once dynamic type loading is switched off. /// /// /// Unlike this table is unconditional. Whether a binding survives is @@ -261,15 +251,21 @@ public static T WithTransport(ActorSystem system, Address address, Func ac new(StringComparer.Ordinal) { ["System.Byte[]"] = typeof(byte[]), - ["System.Byte[], mscorlib"] = typeof(byte[]), - ["System.Byte[], System.Private.CoreLib"] = typeof(byte[]), - ["System.Byte[], System.Runtime"] = typeof(byte[]), - ["System.Byte[], netstandard"] = typeof(byte[]), - ["System.Object"] = typeof(object), - ["System.Object, mscorlib"] = typeof(object), - ["System.Object, System.Private.CoreLib"] = typeof(object), - ["System.Object, System.Runtime"] = typeof(object), - ["System.Object, netstandard"] = typeof(object) + ["System.Object"] = typeof(object) + }; + + /// + /// The framework assembly names accepts alongside "no + /// assembly at all" - every name the .NET runtimes Akka.NET targets have shipped + /// and [] under. + /// + private static readonly HashSet FrameworkAssemblyNames = + new(StringComparer.OrdinalIgnoreCase) + { + "mscorlib", + "System.Private.CoreLib", + "System.Runtime", + "netstandard" }; /// @@ -345,7 +341,8 @@ public Serialization(ExtendedActorSystem system) Serializer serializer; if (serializerTypeName is not null && - BuiltInSerializers.TryGetValue(Akka.Util.TypeExtensions.StripAssemblyIdentity(serializerTypeName), out var serializerFactory)) + Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(serializerTypeName) is { } builtInSerializerName && + BuiltInSerializers.TryGetValue(builtInSerializerName, out var serializerFactory)) { serializer = serializerFactory(system, serializerConfig); if (serializer == null) @@ -392,7 +389,9 @@ public Serialization(ExtendedActorSystem system) var serializerName = kvp.Value.GetString(); Type messageType; - if (BuiltInSerializationBindings.TryGetValue(Akka.Util.TypeExtensions.StripAssemblyIdentity(typename), out var builtInType)) + if (Akka.Util.TypeExtensions.TrySplitTypeName(typename, out var bindingName, out var bindingAssembly) && + (bindingAssembly is null || FrameworkAssemblyNames.Contains(bindingAssembly)) && + BuiltInSerializationBindings.TryGetValue(bindingName, out var builtInType)) { messageType = builtInType; } @@ -406,7 +405,9 @@ public Serialization(ExtendedActorSystem system) } } else if ((setupTypesByName ??= SetupTypesByName(_serializerDetails)) - .TryGetValue(Akka.Util.TypeExtensions.StripAssemblyIdentity(typename), out var setupType)) + .TryGetValue(bindingName, out var setupType) && + (bindingAssembly is null || + string.Equals(bindingAssembly, setupType.Assembly.GetName().Name, StringComparison.OrdinalIgnoreCase))) { // one of the types a SerializationSetup binds, so the name resolves without reflection messageType = setupType; @@ -443,9 +444,9 @@ public Serialization(ExtendedActorSystem system) } /// - /// Every type a binds, keyed by the two spellings a HOCON binding row can - /// reach once has run on it: the bare full - /// name, and the full name plus assembly simple name. + /// Every type a binds, keyed by bare full name. A binding row is split + /// with like every other lookup here, and its + /// assembly half, when present, has to match the type's assembly simple name. /// private static Dictionary SetupTypesByName(IEnumerable details) { @@ -455,8 +456,9 @@ private static Dictionary SetupTypesByName(IEnumerable /// The keys under akka.actor.serialization-identifiers are matched against the name of the /// that is already in hand, rather than resolved back into a . - /// Each key is run through - /// and then split at the comma that - /// separates the type name from the assembly name: the type name is compared case-sensitively, the - /// assembly name case-insensitively, which is how compared them. + /// Each key is split with : the type name is + /// compared case-sensitively, the assembly name case-insensitively, which is how + /// compared them. /// /// /// Assembly-qualified keys are matched across the whole block first, and only then bare /// keys, so a bare key cannot shadow an exact assembly-qualified key - /// further down the block. A bare key matches a type of that name in any assembly. + /// further down the block. A bare key matches a type of that name in any assembly. Unlike the + /// BuiltIn* tables elsewhere, a match here is not limited to the Akka assembly: the + /// type's own assembly, whatever it is, has to match an assembly-qualified key. /// /// /// Matching by name also fixes a latent bug. The lookup used to call @@ -251,23 +252,19 @@ public static int GetSerializerIdentifierFromConfig(Type type, ExtendedActorSyst */ // TypeQualifiedName() is the cached "Namespace.Type, Assembly" spelling with the assembly identity - // already stripped, including inside a generic type's arguments. Split it the same way the keys are - // split so the two halves are guaranteed to line up. Nested types spell with '+' on both sides. - var qualifiedName = type.TypeQualifiedName(); - var separator = IndexOfAssemblySeparator(qualifiedName); - var fullName = separator < 0 ? qualifiedName : qualifiedName.Substring(0, separator).TrimEnd(); - var assemblyName = separator < 0 ? string.Empty : qualifiedName.Substring(separator + 1).Trim(); + // already stripped, including inside a generic type's arguments. Nested types spell with '+' on + // both sides. + Akka.Util.TypeExtensions.TrySplitTypeName(type.TypeQualifiedName(), out var fullName, out var assemblyName); // Pass 1: assembly-qualified keys, over the whole block, so one of them always beats a bare key. foreach (var pair in config.AsEnumerable()) { - var key = Akka.Util.TypeExtensions.StripAssemblyIdentity(pair.Key); - var keySeparator = IndexOfAssemblySeparator(key); - if (keySeparator < 0) + if (!Akka.Util.TypeExtensions.TrySplitTypeName(pair.Key, out var keyName, out var keyAssembly) || + keyAssembly is null) continue; - if (string.Equals(key.Substring(0, keySeparator).TrimEnd(), fullName, StringComparison.Ordinal) && - string.Equals(key.Substring(keySeparator + 1).Trim(), assemblyName, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(keyName, fullName, StringComparison.Ordinal) && + string.Equals(keyAssembly, assemblyName ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { return pair.Value.GetInt(); } @@ -276,38 +273,13 @@ public static int GetSerializerIdentifierFromConfig(Type type, ExtendedActorSyst // Pass 2: bare keys. foreach (var pair in config.AsEnumerable()) { - var key = Akka.Util.TypeExtensions.StripAssemblyIdentity(pair.Key); - if (IndexOfAssemblySeparator(key) < 0 && string.Equals(key, fullName, StringComparison.Ordinal)) + if (Akka.Util.TypeExtensions.TrySplitTypeName(pair.Key, out var keyName, out var keyAssembly) && + keyAssembly is null && + string.Equals(keyName, fullName, StringComparison.Ordinal)) return pair.Value.GetInt(); } throw new ArgumentException($"Couldn't find serializer id for [{type}] under [{SerializationIdentifiers}] HOCON path", nameof(type)); } - - /// - /// The index of the comma that separates the type name from the assembly name, which is the first comma - /// at bracket depth zero - the commas inside a generic type's argument list do not count. - /// - /// The index, or -1 when carries no assembly name. - private static int IndexOfAssemblySeparator(string typeName) - { - var depth = 0; - for (var i = 0; i < typeName.Length; i++) - { - switch (typeName[i]) - { - case '[': - depth++; - break; - case ']': - depth--; - break; - case ',' when depth == 0: - return i; - } - } - - return -1; - } } } diff --git a/src/core/Akka/Util/AkkaFeatures.cs b/src/core/Akka/Util/AkkaFeatures.cs index dd6e53e7b92..99d97458e0f 100644 --- a/src/core/Akka/Util/AkkaFeatures.cs +++ b/src/core/Akka/Util/AkkaFeatures.cs @@ -56,13 +56,13 @@ internal static class AkkaFeatures /// at runtime has no effect on it. /// /// - /// Every BuiltIn* table carries exactly two keys per type - the bare Ns.T and the - /// Ns.T, Akka form - and the lookup runs the configured value through - /// first. That is how a full - /// , which Akka.Hosting writes into HOCON, matches the second - /// key regardless of the version, culture or public key token it names. Never add a third key spelled - /// typeof(T).AssemblyQualifiedName: it roots nothing the table does not already root, and it - /// only ever matches the version of the build that produced it. + /// Every BuiltIn* table carries exactly one key per type - the bare Ns.T - and the + /// lookup runs the configured value through first, + /// which accepts the bare name and every spelling of Ns.T, Akka that + /// itself accepted - any assembly identity, optional space after the comma, any casing of the + /// assembly name - and returns null for anything naming a different assembly. Never key a + /// table on typeof(T).AssemblyQualifiedName: it roots nothing the bare name does not already + /// root, and it only ever matches the version of the build that produced it. /// /// /// A call site must not Trim() the value it reads: Akka's HOCON parser already strips leading diff --git a/src/core/Akka/Util/TypeExtensions.cs b/src/core/Akka/Util/TypeExtensions.cs index 6c4ebb000b2..78248beb706 100644 --- a/src/core/Akka/Util/TypeExtensions.cs +++ b/src/core/Akka/Util/TypeExtensions.cs @@ -76,6 +76,101 @@ public static bool Implements(this Type type, Type moreGeneralType) internal static string StripAssemblyIdentity(string typeName) => AssemblyIdentityRegex().Replace(typeName, string.Empty); +#nullable enable + + /// + /// INTERNAL API + /// + /// Splits a type name into the type name and, if present, the assembly name - the same split + /// makes at the first comma outside a generic argument list. + /// Runs first, so an assembly-qualified name reduces to its + /// bare Ns.T, Assembly form before the split. + /// + /// The type name to split. + /// + /// The type name. Trimmed when carries an assembly name, since it sits + /// before the separating comma; untouched otherwise, since a HOCON value already arrives pre-trimmed + /// and a HOCON key does not. Empty when this method returns false. + /// + /// The trimmed assembly name, or null when carries none. + /// false when is null, empty or whitespace; true otherwise. + internal static bool TrySplitTypeName(string? typeName, out string name, out string? assembly) + { + if (string.IsNullOrWhiteSpace(typeName)) + { + name = string.Empty; + assembly = null; + return false; + } + + // No separator: the whole (stripped) string is the name, untouched - a HOCON value arrives + // pre-trimmed by the parser, and a HOCON key does not, so trimming it here would let two + // differently-padded keys collide on the same built-in type once both hit the table. + var stripped = StripAssemblyIdentity(typeName); + var separator = IndexOfAssemblySeparator(stripped); + if (separator < 0) + { + name = stripped; + assembly = null; + } + else + { + name = stripped.Substring(0, separator).TrimEnd(); + assembly = stripped.Substring(separator + 1).Trim(); + } + + return true; + } + + /// + /// INTERNAL API + /// + /// Returns the bare type name when names an Akka.NET type - no assembly + /// at all, or the Akka assembly in any casing - and null otherwise. This is what every + /// BuiltIn* table lookup normalizes a HOCON value to before probing its single key per type. + /// + /// The type name read out of HOCON. + internal static string? ToBuiltInAkkaTypeName(string? typeName) + { + if (!TrySplitTypeName(typeName, out var name, out var assembly)) + return null; + + return assembly is null || string.Equals(assembly, "Akka", StringComparison.OrdinalIgnoreCase) + ? name + : null; + } + + /// + /// INTERNAL API + /// + /// The index of the comma that separates a type name from its assembly name - the first comma at + /// bracket depth zero, so the commas inside a generic type's argument list do not count. + /// + /// The (already assembly-identity-stripped) type name to scan. + /// The index, or -1 when carries no assembly name. + internal static int IndexOfAssemblySeparator(string typeName) + { + var depth = 0; + for (var i = 0; i < typeName.Length; i++) + { + switch (typeName[i]) + { + case '[': + depth++; + break; + case ']': + depth--; + break; + case ',' when depth == 0: + return i; + } + } + + return -1; + } + +#nullable restore + /// /// INTERNAL API /// Utility to be used by implementers to create a manifest from the type.