fix(opcuaclient): #676 re-review — trailing read skips refs delivered to while in flight

1. A per-ref delivery counter (bumped under the delivery lock by every publish path) is
   captured before the trailing read is issued; a ref delivered to since is skipped.
   Without source timestamps the read's sample could not be ordered against a notification
   that landed mid-read, and publishing it regressed the node. Test injects the
   notification inside the mocked read, with and without a read source time (red before).
2. Delivery-lock comment corrected (it serialises across subscriptions, which the SDK
   dispatches on separate workers); PublishCommsLoss now publishes under the lock too.
3. CancelSessionWork cancels without disposing, so a keep-alive thread reading the old
   token cannot hit ObjectDisposedException.
This commit is contained in:
Joseph Doherty
2026-10-11 11:44:12 -04:00
parent 30a9d5f094
commit 27bf4826d6
3 changed files with 94 additions and 23 deletions
@@ -614,4 +614,49 @@ public sealed class OpcUaClientConnectionLossSignalTests
await drv.ReconcileTaskForTest.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
/// <summary>
/// Re-review of 30a9d5f0: with no source timestamps to compare, "newer" cannot be judged from the values. The
/// trailing read samples V1; while it is in flight a live notification delivers V2; the read then returns V1 —
/// different from the last delivered value and with no timestamp to prove it older. It must NOT be published: a
/// ref delivered to after the read was issued is skipped (per-ref delivery sequence), or the node regresses to V1.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_notification_delivered_while_the_trailing_read_is_in_flight_is_not_overwritten(bool readHasSourceTime)
{
var (drv, session, clock, _) = NewConnectedDriver();
using var _ = drv;
drv.PostRecoveryReconcileDelay = () => TimeSpan.Zero;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
DataValue NoSource(object v, DateTime server) => new(new Variant(v), StatusCodes.Good, DateTime.MinValue, server);
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(10f, t0));
var reads = 0;
session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
DataValue result;
if (Interlocked.Increment(ref reads) == 1)
result = NoSource(10f, t0.AddSeconds(1)); // recovery read: unchanged
else
{
// The trailing read has sampled V1 at the server; before its response is processed, the live
// subscription delivers the newer V2.
drv.DeliverNotificationForTest(handle, SpeedRef, NoSource(20f, t0.AddSeconds(9)));
result = readHasSourceTime
? Good(15f, t0.AddSeconds(8)) // read carries a source time, V2 did not
: NoSource(15f, t0.AddSeconds(8));
}
return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [result], DiagnosticInfos = [] };
});
var seen = await RecoverAndReconcileAsync(drv);
reads.ShouldBe(2, "the trailing read must have run");
lock (seen) seen.Select(e => e.Value).ShouldBe([10f, 10f, 20f], "the read's stale 15 must not overwrite the newer 20");
}
}