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).
This commit is contained in:
Joseph Doherty
2026-10-11 11:35:50 -04:00
parent fccffd0a0f
commit 30a9d5f094
10 changed files with 491 additions and 111 deletions
@@ -44,9 +44,12 @@ public sealed class OpcUaClientDriverStallWireTests
await using var proxy = TcpStallProxy.Start(new Uri(rig.EndpointUrl).Port);
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
// A 3 s operation timeout keeps the stall short: a keep-alive is declared failed when its request times out.
// A 3 s operation timeout keeps the stall short (a keep-alive is declared failed when its request times out); a
// 120 s session timeout and a 3000-cycle (300 s at 100 ms) subscription lifetime keep both far outside the
// stall, so the test exercises the in-place recovery and nothing else.
await using var drv = OpcUaClientDriverWireTests.NewDriver(
rig, "wire-stall", endpointUrl: proxy.Through(rig.EndpointUrl), timeout: TimeSpan.FromSeconds(3));
rig, "wire-stall", endpointUrl: proxy.Through(rig.EndpointUrl), timeout: TimeSpan.FromSeconds(3),
sessionTimeout: TimeSpan.FromSeconds(120), subscriptionLifetimeCount: 3000);
var connectionLost = 0;
drv.ConnectionLost += (_, _) => Interlocked.Increment(ref connectionLost);
await drv.InitializeAsync("{}", ct);
@@ -63,10 +66,19 @@ public sealed class OpcUaClientDriverStallWireTests
var session = drv.Session.ShouldNotBeNull();
var connectionsBefore = proxy.AcceptedConnections;
// Bad keep-alive TICKS: the SDK reports a failed tick as two events at the same instant (the request's status and
// BadNoCommunication), so events closer than half a keep-alive interval are one tick.
var badKeepAlives = 0;
var lastBadTick = DateTime.MinValue;
session.KeepAlive += (_, e) =>
{
if (ServiceResult.IsBad(e.Status)) Interlocked.Increment(ref badKeepAlives);
if (!ServiceResult.IsBad(e.Status)) return;
lock (seen)
{
if (DateTime.UtcNow - lastBadTick < TimeSpan.FromMilliseconds(500)) return;
lastBadTick = DateTime.UtcNow;
badKeepAlives++;
}
};
// The SDK's late backlog flush: a data-carrying NotificationMessage whose sequence number is BELOW one already
// seen (keep-alive messages carry the next number). It is the positive control for the no-regression check.
@@ -97,9 +109,10 @@ public sealed class OpcUaClientDriverStallWireTests
await Task.Delay(300, ct); // > one publishing interval, so the server queues 20.0 as its own notification
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
// Stays Bad and frozen across a further failed keep-alive (counted, not slept for).
var badAtDetection = Volatile.Read(ref badKeepAlives);
await WaitAsync(() => Volatile.Read(ref badKeepAlives) >= badAtDetection + 2, "further keep-alives must fail while stalled");
// Stays Bad and frozen across one further failed keep-alive tick (counted, not slept for).
int badAtDetection;
lock (seen) badAtDetection = badKeepAlives;
await WaitAsync(() => { lock (seen) return badKeepAlives > badAtDetection; }, "a further keep-alive must fail while stalled");
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "the read stamp is the #527 watchdog's silence measure");
lock (seen) seen.Count(s => s.Status == BadNoCommunication).ShouldBe(1, "one Bad per outage, not per keep-alive");
@@ -118,13 +131,15 @@ public sealed class OpcUaClientDriverStallWireTests
// being answered or timed out — must still reach the node.
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 40.0f);
await WaitAsync(() => { lock (seen) return seen.Any(s => s is { Status: 0u, Value: 40.0f }); },
"a change published right after recovery must reach the node (via the subscription or the trailing re-read)");
"a change published right after recovery must reach the node (via the subscription or the trailing re-read)",
TimeSpan.FromSeconds(60));
await drv.ReconcileTaskForTest;
// The SDK flushes the notifications held during the stall seconds after recovery (the #670 F2 shape). Wait for
// that flush before judging ordering, or the check below would pass on a run where nothing late arrived.
await WaitAsync(() => { lock (seen) return lateMessages > 0; },
"the SDK's late backlog flush did not arrive — the no-regression check below would be vacuous");
"the SDK's late backlog flush did not arrive — the no-regression check below would be vacuous",
TimeSpan.FromSeconds(60));
lock (seen)
{
@@ -41,7 +41,8 @@ public sealed class OpcUaClientDriverWireTests
internal static OpcUaClientDriver NewDriver(
ServerEventRig rig, string id, string? browseRoot = null, IReadOnlyList<RawTagEntry>? extraTags = null,
string? endpointUrl = null, TimeSpan? timeout = null) =>
string? endpointUrl = null, TimeSpan? timeout = null, TimeSpan? sessionTimeout = null,
uint? subscriptionLifetimeCount = null) =>
new(new OpcUaClientDriverOptions
{
EndpointUrl = endpointUrl ?? rig.EndpointUrl,
@@ -59,7 +60,8 @@ public sealed class OpcUaClientDriverWireTests
Password = ServerEventRig.KeyPassword,
AutoAcceptCertificates = true,
Timeout = timeout ?? TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
SessionTimeout = sessionTimeout ?? TimeSpan.FromSeconds(30),
SubscriptionLifetimeCount = subscriptionLifetimeCount ?? 1000,
KeepAliveInterval = TimeSpan.FromSeconds(1),
BrowseRoot = browseRoot,
}, id);
@@ -1,4 +1,5 @@
using Akka.Actor;
using Akka.Event;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -126,6 +127,23 @@ public sealed class DriverInstanceActorConnectionLossSignalTests : RuntimeActorT
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()
{