Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorConnectionLossSignalTests.cs
T
Joseph Doherty 30a9d5f094 fix(opcuaclient): #676 review fix-ups (port-less rewrite, full reconcile, delivery lock, timers)
1. RewriteEndpointUrl keeps the advertised port when the configured URL names none
   (Uri.Port -1 would have sent the session to 4840); port-less + IPv6 tests.
2. The trailing re-read repairs any (value, status) difference that is not older than
   the last delivered value — Good->Bad/Uncertain included; a read without a source
   timestamp counts as newer; an equal source time with different content is published
   (repairs the replay guard's by-design equal-timestamp drop).
3. One driver-wide delivery lock makes decide+publish atomic across live notifications,
   the recovery read and the trailing read, so an older value can never land after a
   newer one (deterministic race test via a seam).
4. Known gap documented: a ConnectionLost raised while the host is not Connected is
   swallowed and that session falls back to the watchdog.
5. Only a SERVER-reported subscription loss raises ConnectionLost; the publishing-stall
   heuristic stays with the #527 watchdog.
6. Post-recovery timers run on Task.Delay(…, Clock, ct) with a session-work CTS
   cancelled on session release and dispose.
7. Replay summary at Information only when something was dropped (Debug otherwise);
   a re-arm reports the superseded window's drops and counts afresh.
8. DriverInstanceActor: AttachHostStatusSource doc comment restored; Reconnecting
   swallows a stray DisconnectObserved (test).
9. Stall wire test: 120 s session timeout, 3000-cycle subscription lifetime, one further
   bad keep-alive tick, 60 s headroom on the post-recovery waits (5/5 green).
2026-10-11 11:35:53 -04:00

176 lines
8.0 KiB
C#

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;
/// <summary>
/// Gitea #676 — a driver that KNOWS its connection is gone (<see cref="IDriverConnectionLossSource"/>) 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.
/// </summary>
public sealed class DriverInstanceActorConnectionLossSignalTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
/// <summary>A permanently Healthy (fresh-read) stub — the watchdog can never fire for it — that can raise
/// <see cref="IDriverConnectionLossSource.ConnectionLost"/> on demand and counts its attached handlers.</summary>
private sealed class LossSourceStubDriver : StubDriver, IDriverConnectionLossSource
{
private EventHandler<DriverConnectionLossEventArgs>? _handlers;
private int _handlerCount;
private int _healthReads;
public int HandlerCount => Volatile.Read(ref _handlerCount);
/// <summary>Health-poll observations — the positive control for an absence assertion.</summary>
public int HealthReadCount => Volatile.Read(ref _healthReads);
public override DriverHealth GetHealth()
{
Interlocked.Increment(ref _healthReads);
return base.GetHealth();
}
public event EventHandler<DriverConnectionLossEventArgs>? ConnectionLost
{
add { _handlers += value; Interlocked.Increment(ref _handlerCount); }
remove { _handlers -= value; Interlocked.Decrement(ref _handlerCount); }
}
/// <summary>Raised from a pool thread, as a real driver's keep-alive callback would.</summary>
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<DriverInstanceActor.ConnectivityChanged>(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<DriverInstanceActor.ConnectivityChanged>(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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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<DriverInstanceActor.ConnectivityChanged>(TimeSpan.FromSeconds(3)).Connected.ShouldBeTrue();
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(600));
driver.InitializeCount.ShouldBe(1);
}
/// <summary>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.</summary>
[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<DriverInstanceActor.ConnectivityChanged>(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");
}
/// <summary>A driver that does not implement the interface is simply never subscribed — the signal is opt-in.</summary>
[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<DriverInstanceActor.ConnectivityChanged>(TimeSpan.FromSeconds(3)).Connected.ShouldBeTrue();
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}