fccffd0a0f
- Endpoint rewrite: the driver now connects to the configured host AND port (scheme match, advertised path kept) instead of the server-advertised URL — fixes NAT/port-map upstreams and lets an in-test proxy carry the session (SDK SelectEndpoint rule). - TcpStallProxy + OpcUaClientDriverStallWireTests: the in-place blip (#670) is now wire-tested on loopback — one Bad, frozen read stamp, same session, identical value republished Good, a post-recovery change arrives, no source-time regression after the SDK's late backlog flush. - Defect found by that test, fixed: a change published right after a stall rode a Publish response the SDK timed out and never republished, leaving the node Good on the pre-change value indefinitely. New trailing reconcile read (Timeout + KeepAliveInterval after recovery) publishes any Good value newer than the last accepted one, through the replay guard's mark. - IDriverConnectionLossSource (Core.Abstractions): opt-in definitive-loss signal. DriverInstanceActor attaches in PreStart / detaches in PostStop and turns a raise into a disconnect only while Connected. OpcUaClient raises once per session on keep-alive BadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/ BadServerHalted (measured: the SDK never re-opens a halted channel) or a dropped subscription; ambiguous codes stay with the #527 watchdog. - Replay guard: one Information summary per recovery when the window closes ("dropped N late notifications across M refs"), with a timer so quiet subscriptions log it too. - ReconnectPeriod deleted (options, AdminUI form, docs, tests); stored configs that carry reconnectPeriod still parse in the factory, probe and browser. - Docs: OpcUaClient.md, DriverLifecycle.md, driver-specs.md, CLAUDE.md.
158 lines
7.1 KiB
C#
158 lines
7.1 KiB
C#
using Akka.Actor;
|
|
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);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|