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:
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user