diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs index 856432f7..e60fd276 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs @@ -32,11 +32,35 @@ public interface ISiteClientFactory /// public class DefaultSiteClientFactory : ISiteClientFactory { + /// + /// Per-incarnation generation counter. Context.Stop of the previous client is + /// asynchronous — its actor name stays reserved until termination completes — + /// so a same-named recreate in the same message handling throws + /// InvalidActorNameException. A generation suffix makes every incarnation's + /// name unique by construction (mirrors SiteStreamGrpcServer._actorCounter). + /// + private long _generation; + /// public IActorRef Create(ActorSystem system, string siteId, ImmutableHashSet contacts) { var settings = ClusterClientSettings.Create(system).WithInitialContacts(contacts); - return system.ActorOf(ClusterClient.Props(settings), $"site-client-{siteId}"); + var name = $"site-client-{SanitizeForActorName(siteId)}-{System.Threading.Interlocked.Increment(ref _generation)}"; + return system.ActorOf(ClusterClient.Props(settings), name); + } + + /// + /// Maps an arbitrary SiteIdentifier onto a valid Akka actor-path element: + /// letters/digits/'-'/'_' pass through, everything else becomes '_'. Uniqueness + /// is NOT required here (the generation suffix guarantees it); only validity is. + /// + internal static string SanitizeForActorName(string siteId) + { + if (string.IsNullOrEmpty(siteId)) return "site"; + var sanitized = new string(siteId + .Select(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '_') + .ToArray()); + return ActorPath.IsValidPathElement(sanitized) ? sanitized : "site"; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs new file mode 100644 index 00000000..0a216320 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using Akka.Configuration; +using Akka.TestKit.Xunit2; +using Xunit; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; + +public class DefaultSiteClientFactoryTests : TestKit +{ + private static readonly Config TestConfig = ConfigurationFactory.ParseString(@" + akka.actor.provider = cluster + akka.remote.dot-netty.tcp.port = 0 + akka.remote.dot-netty.tcp.hostname = localhost") + .WithFallback(ClusterClientReceptionist.DefaultConfig()); + + public DefaultSiteClientFactoryTests() : base(TestConfig) { } + + private static ImmutableHashSet Contacts() => + ImmutableHashSet.Create(ActorPath.Parse("akka.tcp://other@localhost:2552/system/receptionist")); + + [Fact] + public void Create_TwiceForSameSite_DoesNotCollide() + { + var factory = new DefaultSiteClientFactory(); + var first = factory.Create(Sys, "site-a", Contacts()); + var second = factory.Create(Sys, "site-a", Contacts()); // pre-fix: InvalidActorNameException + Assert.NotEqual(first.Path, second.Path); + } + + [Theory] + [InlineData("site/with/slashes")] + [InlineData("site with spaces")] + [InlineData("näme#!")] + public void Create_WithUnsafeSiteId_SanitizesAndSucceeds(string siteId) + { + var factory = new DefaultSiteClientFactory(); + var client = factory.Create(Sys, siteId, Contacts()); // pre-fix: InvalidActorNameException + Assert.NotNull(client); + } + + [Theory] + [InlineData("plant-01", "plant-01")] + [InlineData("a/b c", "a_b_c")] + [InlineData("", "site")] + public void SanitizeForActorName_ProducesValidPathElement(string input, string expected) + { + Assert.Equal(expected, DefaultSiteClientFactory.SanitizeForActorName(input)); + Assert.True(ActorPath.IsValidPathElement( + DefaultSiteClientFactory.SanitizeForActorName(input))); + } +}