using Akka.Actor; using Akka.Event; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Gitea #676 — a driver that KNOWS its connection is gone () gets /// re-initialised at once instead of after the #527 watchdog's silence window (~90 s in production). The watchdog is /// configured at an hour here, so anything that reconnects within seconds can only be the new signal. /// public sealed class DriverInstanceActorConnectionLossSignalTests : RuntimeActorTestBase { private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50); /// A permanently Healthy (fresh-read) stub — the watchdog can never fire for it — that can raise /// on demand and counts its attached handlers. private sealed class LossSourceStubDriver : StubDriver, IDriverConnectionLossSource { private EventHandler? _handlers; private int _handlerCount; private int _healthReads; public int HandlerCount => Volatile.Read(ref _handlerCount); /// Health-poll observations — the positive control for an absence assertion. public int HealthReadCount => Volatile.Read(ref _healthReads); public override DriverHealth GetHealth() { Interlocked.Increment(ref _healthReads); return base.GetHealth(); } public event EventHandler? ConnectionLost { add { _handlers += value; Interlocked.Increment(ref _handlerCount); } remove { _handlers -= value; Interlocked.Decrement(ref _handlerCount); } } /// Raised from a pool thread, as a real driver's keep-alive callback would. public Task RaiseAsync(string reason) => Task.Run(() => _handlers?.Invoke(this, new DriverConnectionLossEventArgs(reason))); } private (IActorRef Actor, Akka.TestKit.TestProbe Parent) Connect(LossSourceStubDriver driver) { var parent = CreateTestProbe(); var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, reconnectInterval: TimeSpan.FromHours(1), healthPollInterval: FastPoll, commsLossTimeout: TimeSpan.FromHours(1))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); parent.ExpectMsg(TimeSpan.FromSeconds(3)).Connected.ShouldBeTrue(); return (actor, parent); } [Fact] public async Task A_connection_loss_from_a_connected_driver_reconnects_at_once_not_after_the_watchdog() { var driver = new LossSourceStubDriver(); var (_, parent) = Connect(driver); var started = DateTime.UtcNow; await driver.RaiseAsync("keep-alive reported BadServerHalted"); parent.ExpectMsg(TimeSpan.FromSeconds(3)).Connected.ShouldBeFalse(); (DateTime.UtcNow - started).ShouldBeLessThan(TimeSpan.FromSeconds(3), "the watchdog is configured at an hour, so only the connection-loss signal can have done this"); } /// /// The falsification control: the same driver and actor with the event suppressed stays Connected — so the /// Reconnecting above is the signal's doing, not something the setup would have done anyway. /// [Fact] public void Without_a_raise_the_same_driver_stays_connected() { var driver = new LossSourceStubDriver(); var (_, parent) = Connect(driver); var ticksAtConnect = driver.HealthReadCount; parent.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); (driver.HealthReadCount - ticksAtConnect).ShouldBeGreaterThanOrEqualTo(5, "health-poll ticks did not run — the absence assertion above would be vacuous"); driver.HandlerCount.ShouldBe(1, "attached, so the absence above is not merely an unwired event"); driver.InitializeCount.ShouldBe(1, "and nothing re-initialised the driver"); } /// /// A raise while NOT Connected is ignored, not deferred: here it lands while the initial connect is still in /// flight. Acting on it later would tear down the connection that very attempt is building. /// [Fact] public async Task A_raise_while_connecting_is_ignored() { var driver = new LossSourceStubDriver(); var release = new TaskCompletionSource(); var initStarted = new TaskCompletionSource(); driver.InitBehavior = async _ => { initStarted.TrySetResult(); await release.Task; }; var parent = CreateTestProbe(); var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, reconnectInterval: TimeSpan.FromHours(1), healthPollInterval: FastPoll, commsLossTimeout: TimeSpan.FromHours(1))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); await initStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); // The raise is enqueued before the connect can complete (RaiseAsync returns after the Tell), so the mailbox // delivers it while the actor is still Connecting. await driver.RaiseAsync("a loss reported mid-connect"); release.SetResult(); parent.ExpectMsg(TimeSpan.FromSeconds(3)).Connected.ShouldBeTrue(); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); driver.InitializeCount.ShouldBe(1); } /// Review of 3b88056f: a watchdog DisconnectObserved still queued when the connection-loss signal has /// already moved the actor to Reconnecting is a no-op there, not an Unhandled message. [Fact] public async Task A_stray_DisconnectObserved_while_reconnecting_is_not_unhandled() { var driver = new LossSourceStubDriver(); var (actor, parent) = Connect(driver); var unhandled = CreateTestProbe(); Sys.EventStream.Subscribe(unhandled.Ref, typeof(UnhandledMessage)); await driver.RaiseAsync("keep-alive reported BadServerHalted"); parent.ExpectMsg(TimeSpan.FromSeconds(3)).Connected.ShouldBeFalse(); actor.Tell(new DriverInstanceActor.DisconnectObserved("late watchdog trip")); unhandled.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void The_handler_is_attached_once_and_detached_when_the_actor_stops() { var driver = new LossSourceStubDriver(); var (actor, parent) = Connect(driver); driver.HandlerCount.ShouldBe(1); parent.Watch(actor); Sys.Stop(actor); // PostStop announces comms loss (03/S15) before the Terminated arrives. parent.FishForMessage(m => m is Terminated t && t.ActorRef.Equals(actor), TimeSpan.FromSeconds(3)); driver.HandlerCount.ShouldBe(0, "the IDriver outlives the actor on a respawn — a leaked handler holds a dead Self"); } /// A driver that does not implement the interface is simply never subscribed — the signal is opt-in. [Fact] public void A_driver_without_the_capability_connects_as_before() { var driver = new StubDriver(); var parent = CreateTestProbe(); var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, reconnectInterval: TimeSpan.FromHours(1), healthPollInterval: FastPoll, commsLossTimeout: TimeSpan.FromHours(1))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); parent.ExpectMsg(TimeSpan.FromSeconds(3)).Connected.ShouldBeTrue(); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } }