Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSessionLossTests.cs
T
Joseph Doherty 099bc1b6e0 fix(opcuaclient): #670 F2 re-gate — hold the replay guard for the whole window
The F2 live gate failed (10 value regressions over 4 blips): IsLateReplay
disarmed a ref on its first in-order notification, which arrives ~1 s after
recovery, while the SDK's late replay lands 4-6 s later. Equal source times
also passed, so a replay equal to the recovery read got through.

While the window is open each ref now carries a high-water mark (seeded at
arming, raised by the recovery read, advanced by every accepted notification);
a notification at or below it is dropped (equal included — OPC UA moves the
source timestamp on every value/status change). Null source time passes
without moving the mark. Window expiry still disarms entirely, so an upstream
clock step-back is never frozen outside the window. Arm/expiry serialised by a
lock so an expiry racing a re-arm cannot wipe fresh seeds.
2026-10-11 09:42:39 -04:00

723 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Gitea #670 — upstream session loss must reach the driver's health and its subscribers. Before the fix a bad
/// keep-alive only flipped the host status: the subscribed values stayed Good forever, health stayed Healthy (so the
/// #527 comms-loss watchdog could never trip — its first conjunct is "non-Healthy"), and <c>LastSuccessfulRead</c>
/// only moved on init / <c>ReadAsync</c>, which a subscription-only driver never calls.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientSessionLossTests
{
private const uint BadNoCommunication = 0x80310000u;
private const string SpeedRef = "Plant/dev/Speed";
private const string CounterRef = "Plant/dev/Counter";
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
public void Advance(TimeSpan by) => Now += by;
}
private static RawTagEntry Tag(string rawPath, string nodeId) =>
new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
private static readonly IReadOnlyList<RawTagEntry> Tags =
[
Tag(SpeedRef, "ns=0;s=Speed"),
Tag(CounterRef, "ns=0;s=Counter"),
];
private static Mock<ISession> NewSession()
{
var mock = new Mock<ISession>(MockBehavior.Loose);
mock.SetupGet(s => s.MessageContext)
.Returns(new ServiceMessageContext(SdkTelemetry.Context));
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
return mock;
}
private static (OpcUaClientDriver Driver, Mock<ISession> Session, ManualClock Clock) NewConnectedDriver(
string driverInstanceId = "opcua-670")
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, driverInstanceId);
var session = NewSession();
var clock = new ManualClock(new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero));
drv.Clock = clock;
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
return (drv, session, clock);
}
private static DataValue Good(object value, DateTime ts) =>
new(new Variant(value), StatusCodes.Good, ts, ts);
private static void SetupRead(Mock<ISession> session, params object[] values) =>
session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [.. nodes.Select((_, i) => new DataValue(new Variant(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
DiagnosticInfos = [],
});
private static async Task WaitForAsync(Func<bool> condition, string because)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10);
}
condition().ShouldBeTrue(because);
}
[Fact]
public void A_bad_keep_alive_degrades_health_and_preserves_the_last_successful_read()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var before = drv.GetHealth().LastSuccessfulRead;
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
var h = drv.GetHealth();
h.State.ShouldNotBe(DriverState.Healthy, "a lost session must be visible to the #527 watchdog's first conjunct");
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead.ShouldBe(before, "the watchdog measures silence from this stamp, so loss must not move it");
h.LastError.ShouldNotBeNull().ShouldContain("keep-alive");
}
[Fact]
public void A_bad_keep_alive_publishes_BadNoCommunication_once_per_ref_retaining_the_value()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef, CounterRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, t0));
drv.DeliverNotificationForTest(handle, CounterRef, Good(7, t0));
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
seen.Count.ShouldBe(2, "exactly one Bad per subscribed ref");
var speed = seen.Single(e => e.FullReference == SpeedRef);
speed.Snapshot.StatusCode.ShouldBe(BadNoCommunication);
speed.Snapshot.Value.ShouldBe(42.5f, "the value is retained — only the quality changes (as the host does)");
speed.SubscriptionHandle.ShouldBe(handle);
seen.Single(e => e.FullReference == CounterRef).Snapshot.Value.ShouldBe(7);
// The SDK re-raises a bad keep-alive every interval while the channel stays down: no further publishes.
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed);
seen.Count.ShouldBe(2, "a second bad keep-alive in the same outage must publish nothing");
}
[Fact]
public void Good_keep_alives_and_notifications_advance_the_last_successful_read()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(TimeSpan.FromSeconds(30));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime);
clock.Advance(TimeSpan.FromSeconds(30));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1.0f, clock.Now.UtcDateTime));
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Falsification control for the R2 half of the fix: a long-running, idle, subscription-only driver whose
/// server is perfectly alive must never look dead to the #527 watchdog (non-Healthy AND silent ≥ 90 s).
/// Without good keep-alives advancing <c>LastSuccessfulRead</c>, fixing R1 alone would trip it on the first
/// bad tick of any connection older than 90 s.
/// </summary>
[Fact]
public void An_idle_healthy_subscription_driver_never_looks_dead_to_the_watchdog()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
drv.AddSubscriptionForTest([SpeedRef]); // static value — no notifications ever arrive
for (var i = 0; i < 60; i++) // 5 minutes of 5 s keep-alives
{
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
(clock.Now.UtcDateTime - h.LastSuccessfulRead!.Value).ShouldBeLessThan(TimeSpan.FromSeconds(90));
}
}
[Fact]
public async Task Recovery_on_the_same_session_republishes_the_identical_value_Good()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, clock.Now.UtcDateTime));
SetupRead(session, 42.5f); // the server still holds the SAME value — it will not resend it
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => { lock (seen) seen.Add(e); };
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
clock.Advance(TimeSpan.FromSeconds(20));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime);
await WaitForAsync(() => { lock (seen) return seen.Count >= 2; }, "the recovery read must publish");
lock (seen)
{
seen.Count.ShouldBe(2);
seen[0].Snapshot.StatusCode.ShouldBe(BadNoCommunication);
seen[1].FullReference.ShouldBe(SpeedRef);
seen[1].SubscriptionHandle.ShouldBe(handle);
seen[1].Snapshot.StatusCode.ShouldBe(0u);
seen[1].Snapshot.Value.ShouldBe(42.5f, "the IDENTICAL value must come back Good — a (value,status) dedup lets it through");
}
}
[Fact]
public async Task A_notification_during_recovery_wins_over_the_recovery_read()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1.0f, clock.Now.UtcDateTime));
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
// A fresh notification arrives before the keep-alive recovers: the read must not overwrite it.
drv.DeliverNotificationForTest(handle, SpeedRef, Good(2.0f, clock.Now.UtcDateTime));
SetupRead(session, 1.0f);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => { lock (seen) seen.Add(e); };
drv.RaiseKeepAliveForTest(StatusCodes.Good);
await Task.Delay(200, TestContext.Current.CancellationToken);
lock (seen) seen.ShouldBeEmpty();
}
[Fact]
public void A_bad_keep_alive_annotates_subscribed_alarm_conditions_quality_only_once()
{
var (drv, _, _) = NewConnectedDriver();
using var _ = drv;
drv.AddAlarmSubscriptionForTest([SpeedRef]);
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => seen.Add(e);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
var e = seen.ShouldHaveSingleItem();
e.ConditionId.ShouldBe(SpeedRef);
e.IsQualityOnly.ShouldBeTrue("an outage says nothing about the alarm state — quality only, no transition");
e.QualityStatusCode.ShouldBe(BadNoCommunication);
}
[Fact]
public async Task Recovery_restores_alarm_condition_quality_Good()
{
var (drv, session, _) = NewConnectedDriver();
using var _ = drv;
SetupRead(session);
drv.AddAlarmSubscriptionForTest([SpeedRef]);
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
await WaitForAsync(() => { lock (seen) return seen.Count >= 2; }, "recovery must re-annotate Good");
lock (seen)
{
seen[1].IsQualityOnly.ShouldBeTrue();
seen[1].QualityStatusCode.ShouldBe(0u);
}
}
[Fact]
public void A_keep_alive_from_a_superseded_session_is_ignored()
{
var (drv, _, _) = NewConnectedDriver();
using var _ = drv;
var stale = NewSession();
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication, stale.Object);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Shutdown_clears_the_outage_so_a_fresh_session_reports_its_own_loss()
{
var (drv, _, _) = NewConnectedDriver();
using var _ = drv;
drv.AddSubscriptionForTest([SpeedRef]);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
// As after a watchdog reinit: a new session + subscription, and a new outage must be reported again.
var fresh = NewSession();
drv.SetSessionForTest(fresh.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.AddSubscriptionForTest([SpeedRef]);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
seen.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>
/// Review M2: a recovery overtaken by a re-initialise (watchdog Shutdown + Initialize) must not leak its
/// outcome onto the NEW session — previously its failed read re-armed the outage latch, so the new session's
/// first real outage was swallowed.
/// </summary>
[Fact]
public async Task A_recovery_overtaken_by_a_reinit_does_not_latch_the_new_session()
{
var ct = TestContext.Current.CancellationToken;
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1.0f, clock.Now.UtcDateTime));
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
await drv.Gate.WaitAsync(ct); // park the recovery read on the gate
drv.RaiseKeepAliveForTest(StatusCodes.Good);
await drv.ShutdownAsync(ct); // the watchdog's re-initialise overtakes it …
var fresh = NewSession(); // … and a new session is up (its reads would fail)
drv.SetSessionForTest(fresh.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
drv.AddSubscriptionForTest([SpeedRef]);
drv.Gate.Release();
await drv.RecoveryTaskForTest;
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the stale recovery must not degrade the new session");
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
seen.ShouldHaveSingleItem("the new session's first outage must be published, not swallowed by a stale latch")
.Snapshot.StatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>
/// Review M1: the subscription dies while the session lives (an outage longer than the subscription lifetime
/// but shorter than the session timeout). Keep-alives stay Good, so without detection the driver ends Healthy
/// with fresh stamps and frozen values forever. It must go Degraded, publish the loss once, and STOP advancing
/// the read stamp — so the watchdog's reinit recreates the subscription.
/// </summary>
[Fact]
public void A_subscription_that_stops_publishing_while_the_session_lives_freezes_the_stamp_and_goes_Bad()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var otherHandle = drv.AddSubscriptionForTest([CounterRef]);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(3.0f, clock.Now.UtcDateTime));
var stalled = false;
drv.IsPublishingStopped = sub => stalled && sub.DisplayName == "opcua-sub-1";
drv.PublishingStallGrace = _ => TimeSpan.FromSeconds(10);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
stalled = true;
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good); // first observation — within grace
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
clock.Advance(TimeSpan.FromSeconds(10));
drv.RaiseKeepAliveForTest(StatusCodes.Good); // grace elapsed
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastError.ShouldNotBeNull().ShouldContain("subscription lost");
var frozen = h.LastSuccessfulRead;
seen.Count(e => e.Snapshot.StatusCode == BadNoCommunication).ShouldBe(2, "every subscribed ref published Bad once");
seen.Single(e => e.FullReference == SpeedRef).Snapshot.Value.ShouldBe(3.0f);
// Nothing may revive the stamp: not good keep-alives, not a bad→good keep-alive cycle, not notifications
// from a still-healthy sibling subscription.
for (var i = 0; i < 30; i++)
{
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.DeliverNotificationForTest(otherHandle, CounterRef, Good(i, clock.Now.UtcDateTime));
}
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
h = drv.GetHealth();
h.State.ShouldBe(DriverState.Degraded);
h.LastSuccessfulRead.ShouldBe(frozen, "the #527 watchdog needs a frozen stamp to re-initialise the driver");
seen.Count(e => e.Snapshot.StatusCode == BadNoCommunication).ShouldBe(2, "the loss is published once per connection");
}
/// <summary>Control for M1: a subscription that is briefly silent (e.g. right after an outage) and then resumes
/// within its grace is NOT declared dead.</summary>
[Fact]
public void A_subscription_silent_for_less_than_its_grace_is_not_declared_lost()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
drv.AddSubscriptionForTest([SpeedRef]);
var stalled = true;
drv.IsPublishingStopped = _ => stalled;
drv.PublishingStallGrace = _ => TimeSpan.FromSeconds(10);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
clock.Advance(TimeSpan.FromSeconds(9));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
stalled = false;
for (var i = 0; i < 10; i++)
{
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
}
stalled = true; // a NEW stall restarts the grace rather than inheriting the old one
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime);
}
[Fact]
public void A_BadNoSubscription_publish_error_with_tracked_subscriptions_declares_the_subscription_lost()
{
var (drv, _, _) = NewConnectedDriver();
using var _ = drv;
drv.AddSubscriptionForTest([SpeedRef]);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.RaisePublishErrorForTest(StatusCodes.BadSubscriptionIdInvalid, subscriptionId: 4242); // not ours
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
seen.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(BadNoCommunication);
}
[Fact]
public void A_BadNoSubscription_with_nothing_tracked_is_not_a_loss()
{
var (drv, _, _) = NewConnectedDriver();
using var _ = drv;
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Shutdown_clears_a_subscription_loss()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
drv.AddSubscriptionForTest([SpeedRef]);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.SetSessionForTest(NewSession().Object);
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
clock.Advance(TimeSpan.FromSeconds(5));
drv.RaiseKeepAliveForTest(StatusCodes.Good);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime, "the re-subscribed connection starts clean");
}
[Fact]
public void Sdk_log_forwarding_is_rate_limited()
{
var target = new CountingLogger();
var limited = new SdkTelemetry.RateLimitedLogger(target, max: 3, window: TimeSpan.FromHours(1));
for (var i = 0; i < 50; i++)
Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(limited, "sdk warning {I}", i);
target.Count.ShouldBe(3);
}
private sealed class CountingLogger : Microsoft.Extensions.Logging.ILogger
{
public int Count;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter) => Count++;
}
/// <summary>
/// #670 follow-up F1 (live gate): the outage Bad carried the last Good sample's SOURCE timestamp, so the
/// recorder (which historizes <c>SourceTimestampUtc ?? ServerTimestampUtc</c>) wrote it at the Good row's key —
/// the historian's insert-if-absent (TagId, SourceTimeUtc) key dropped it and no outage marker was ever stored.
/// The Bad is a fresh observation: it is stamped with the detection time, like the host's own comms-loss fan.
/// </summary>
[Fact]
public void The_outage_Bad_is_stamped_with_the_detection_time_not_the_last_good_source_time()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var goodAt = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, goodAt));
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
clock.Advance(TimeSpan.FromSeconds(37));
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
var bad = seen.ShouldHaveSingleItem().Snapshot;
bad.StatusCode.ShouldBe(BadNoCommunication);
bad.Value.ShouldBe(42.5f, "the value is still retained");
bad.SourceTimestampUtc.ShouldBe(clock.Now.UtcDateTime, "a Bad at the Good row's source time collides with it in history");
bad.ServerTimestampUtc.ShouldBe(clock.Now.UtcDateTime);
}
private static void SetupReadAt(Mock<ISession> session, object value, DateTime sourceTime) =>
session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new DataValue(new Variant(value), StatusCodes.Good, sourceTime, sourceTime)],
DiagnosticInfos = [],
});
/// <summary>Drives a same-session outage + recovery (bad → good keep-alive) and waits for the recovery read.</summary>
private static async Task RecoverInPlaceAsync(OpcUaClientDriver drv)
{
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
await drv.RecoveryTaskForTest;
}
/// <summary>
/// #670 follow-up F2 (live gate): after a same-session recovery the SDK delivered one notification late with
/// an OLDER source time, and the node regressed to it. Inside the post-recovery replay window an older
/// notification is dropped, and so is one carrying the exact source time already accepted (OPC UA moves the
/// source timestamp on every value OR status change, so an equal one is a replay, not a status-only change).
/// </summary>
[Fact]
public async Task After_a_recovery_a_late_older_or_equal_notification_is_dropped()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(668, t0.AddSeconds(2)));
SetupReadAt(session, 668, t0.AddSeconds(2));
await RecoverInPlaceAsync(drv);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(667, t0.AddSeconds(1))); // late replay
drv.DeliverNotificationForTest(handle, SpeedRef,
new DataValue(new Variant(668), StatusCodes.UncertainLastUsableValue, t0.AddSeconds(2), t0.AddSeconds(2))); // same ts
drv.DeliverNotificationForTest(handle, SpeedRef, Good(669, t0.AddSeconds(3)));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(665, t0)); // still guarded
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 669 });
}
/// <summary>
/// #670 F2 re-gate (live gate FAILED on the per-ref disarm): fresh notifications arrived ~1 s after recovery
/// and disarmed each ref, then the SDK's late replay arrived 4–6 s later and regressed the node (value 670 with
/// a pre-outage source time delivered after newer values). The guard must hold for the WHOLE window against the
/// newest ACCEPTED source time per ref — older, equal-to-the-recovery-read and equal-to-the-last-accepted
/// replays are all dropped, and counted.
/// </summary>
[Fact]
public async Task A_late_replay_after_a_fresh_in_order_notification_is_still_dropped_inside_the_window()
{
var instance = $"opcua-670-regate-{Guid.NewGuid():N}";
using var drops = new OpcUaClientDiagnosticsTests.Capture(instance);
var (drv, session, clock) = NewConnectedDriver(instance);
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(100, t0.AddSeconds(-5)));
SetupReadAt(session, 105, t0); // recovery read seeds T0
await RecoverInPlaceAsync(drv);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
clock.Advance(TimeSpan.FromSeconds(1));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(116, t0.AddSeconds(6))); // fresh, in order: T1
clock.Advance(TimeSpan.FromSeconds(5));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(109, t0.AddSeconds(3))); // late replay, older than T1
drv.DeliverNotificationForTest(handle, SpeedRef, Good(105, t0)); // replay == T0 (recovery read)
drv.DeliverNotificationForTest(handle, SpeedRef, Good(116, t0.AddSeconds(6))); // replay == T1
drv.DeliverNotificationForTest(handle, SpeedRef, Good(670, t0.AddSeconds(-10))); // pre-outage replay
drv.DeliverNotificationForTest(handle, SpeedRef, Good(117, t0.AddSeconds(7))); // fresh again
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 116, 117 });
drops[OpcUaClientDiagnostics.StaleNotificationDropped].ShouldBe(4);
}
/// <summary>
/// The re-gated guard still expires: after the window an older source time (an upstream clock step-back) is
/// accepted, even though the ref accepted newer notifications inside the window.
/// </summary>
[Fact]
public async Task After_the_window_a_clock_step_back_is_accepted_even_after_in_window_notifications()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0.AddSeconds(-5)));
SetupReadAt(session, 2, t0);
await RecoverInPlaceAsync(drv);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(3, t0.AddSeconds(20)));
clock.Advance(TimeSpan.FromSeconds(31));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(4, t0.AddSeconds(1))); // clock stepped back
drv.DeliverNotificationForTest(handle, SpeedRef, Good(5, t0)); // and again
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 3, 4, 5 });
}
/// <summary>A notification with no source timestamp passes through the open guard and does not move it.</summary>
[Fact]
public async Task A_notification_without_a_source_time_passes_the_guard_and_does_not_advance_it()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0.AddSeconds(-5)));
SetupReadAt(session, 2, t0);
await RecoverInPlaceAsync(drv);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.DeliverNotificationForTest(handle, SpeedRef,
new DataValue(new Variant(3), StatusCodes.Good, DateTime.MinValue, t0)); // no source time
drv.DeliverNotificationForTest(handle, SpeedRef, Good(4, t0.AddSeconds(-1))); // still guarded at T0
drv.DeliverNotificationForTest(handle, SpeedRef, Good(5, t0.AddSeconds(1)));
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 3, 5 });
}
/// <summary>
/// Coordinator review of F2: with no recovery in play, an upstream clock that steps BACKWARDS (NTP correction,
/// a PLC with a reset clock) must not freeze the node — every notification is published, whatever its time.
/// </summary>
[Fact]
public void In_steady_state_a_clock_step_backwards_still_publishes()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0.AddHours(1)));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(2, t0)); // clock stepped back an hour
drv.DeliverNotificationForTest(handle, SpeedRef, Good(3, t0.AddSeconds(1)));
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 1, 2, 3 });
}
/// <summary>The replay guard is bounded in time: once its window has passed, an older notification is accepted
/// even for a ref that never saw an in-order one.</summary>
[Fact]
public async Task The_replay_guard_expires()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(10, t0.AddSeconds(5)));
SetupReadAt(session, 10, t0.AddSeconds(5));
await RecoverInPlaceAsync(drv);
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
clock.Advance(TimeSpan.FromSeconds(29));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(9, t0));
seen.ShouldBeEmpty("still inside the 30 s window");
clock.Advance(TimeSpan.FromSeconds(2));
drv.DeliverNotificationForTest(handle, SpeedRef, Good(8, t0));
seen.ShouldHaveSingleItem().Snapshot.Value.ShouldBe(8);
}
[Fact]
public void The_outage_Bad_does_not_poison_the_ordering_check()
{
var (drv, _, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0));
clock.Advance(TimeSpan.FromSeconds(60));
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); // stamped t0+60 s
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
// An upstream sample produced during the outage (server-side) and delivered on recovery: older than the Bad's
// detection stamp, newer than the last Good — it is real data and must pass.
drv.DeliverNotificationForTest(handle, SpeedRef, Good(2, t0.AddSeconds(30)));
seen.ShouldHaveSingleItem().Snapshot.Value.ShouldBe(2);
}
[Fact]
public async Task A_late_notification_older_than_the_recovery_read_is_dropped()
{
var (drv, session, clock) = NewConnectedDriver();
using var _ = drv;
var handle = drv.AddSubscriptionForTest([SpeedRef]);
var t0 = clock.Now.UtcDateTime;
drv.DeliverNotificationForTest(handle, SpeedRef, Good(666, t0));
var readAt = t0.AddSeconds(10);
session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new DataValue(new Variant(670), StatusCodes.Good, readAt, readAt)],
DiagnosticInfos = [],
});
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
drv.RaiseKeepAliveForTest(StatusCodes.Good);
await drv.RecoveryTaskForTest;
var seen = new List<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => seen.Add(e);
drv.DeliverNotificationForTest(handle, SpeedRef, Good(667, t0.AddSeconds(5))); // replayed, older than the read
seen.ShouldBeEmpty();
}
}