Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientStaleNoSubscriptionTests.cs
T

290 lines
13 KiB
C#

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 #763 (root cause of #728) — a <c>BadNoSubscription</c> PublishError with no subscription id that answers a
/// publish queued while the driver had just deleted its LAST subscription must not be read as a loss of the
/// subscription registered since. Before the fix any anonymous <c>BadNoSubscription</c> with anything tracked
/// declared the subscription lost: health Degraded, a <c>BadNoCommunication</c> quality-only annotation on every
/// alarm condition and <see cref="IDriverConnectionLossSource.ConnectionLost"/> — so a routine unsubscribe →
/// resubscribe re-initialised the driver. A genuine loss must still be declared.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientStaleNoSubscriptionTests
{
private const uint BadNoCommunication = 0x80310000u;
private const string SpeedRef = "Plant/dev/Speed";
private const string AlarmRef = "Plant/dev/TempHi";
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 readonly IReadOnlyList<RawTagEntry> Tags =
[
new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true),
];
private sealed record Observed(List<AlarmEventArgs> Alarms, List<DataChangeEventArgs> Data,
List<DriverConnectionLossEventArgs> Lost);
private static (OpcUaClientDriver Driver, ManualClock Clock, Observed Seen) NewConnectedDriver()
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-763");
var session = new Mock<ISession>(MockBehavior.Loose);
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var clock = new ManualClock(new DateTimeOffset(2026, 11, 9, 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));
var seen = new Observed([], [], []);
drv.OnAlarmEvent += (_, e) => seen.Alarms.Add(e);
drv.OnDataChange += (_, e) => seen.Data.Add(e);
drv.ConnectionLost += (_, e) => seen.Lost.Add(e);
return (drv, clock, seen);
}
private static void ShouldBeUntouched(OpcUaClientDriver drv, Observed seen, string because)
{
drv.GetHealth().State.ShouldBe(DriverState.Healthy, because);
seen.Lost.ShouldBeEmpty(because);
seen.Alarms.ShouldBeEmpty(because);
seen.Data.ShouldBeEmpty(because);
}
private static void ShouldBeDeclaredLost(OpcUaClientDriver drv, Observed seen, string because)
{
drv.GetHealth().State.ShouldBe(DriverState.Degraded, because);
seen.Lost.ShouldHaveSingleItem(because).Reason.ShouldContain("subscription lost");
}
/// <summary>The #728 wire shape: alarm set released and re-subscribed, then the late error lands.</summary>
[Fact]
public async Task A_stale_BadNoSubscription_after_an_alarm_resubscribe_is_not_a_loss()
{
var (drv, clock, seen) = NewConnectedDriver();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
drv.AddAlarmSubscriptionForTest([AlarmRef]);
clock.Advance(TimeSpan.FromMilliseconds(850)); // measured on the wire: ~0.85 s after the resubscribe
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeUntouched(drv, seen, "the error answers the deleted subscription's publish, not a loss of the new one");
}
[Fact]
public async Task A_stale_BadNoSubscription_after_a_data_resubscribe_is_not_a_loss()
{
var (drv, clock, seen) = NewConnectedDriver();
await using var _ = drv;
var first = drv.AddSubscriptionForTest([SpeedRef]);
await drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(TimeSpan.FromSeconds(1));
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeUntouched(drv, seen, "the error answers the deleted subscription's publish, not a loss of the new one");
}
/// <summary>Positive control for the window: the same error once it has elapsed is a genuine loss.</summary>
[Fact]
public async Task A_BadNoSubscription_after_the_grace_is_still_a_loss()
{
var (drv, clock, seen) = NewConnectedDriver();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
drv.AddAlarmSubscriptionForTest([AlarmRef]);
clock.Advance(OpcUaClientDriver.StaleNoSubscriptionGrace);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeDeclaredLost(drv, seen, "past the grace the server has dropped the new subscription");
seen.Alarms.ShouldHaveSingleItem().QualityStatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>Genuine loss with no recent delete at all — the pre-#763 behaviour, kept.</summary>
[Fact]
public void A_BadNoSubscription_with_no_recent_delete_is_a_loss()
{
var (drv, _, seen) = NewConnectedDriver();
using var _ = drv;
drv.AddAlarmSubscriptionForTest([AlarmRef]);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeDeclaredLost(drv, seen, "the server holds none of the subscriptions the driver tracks");
seen.Alarms.ShouldHaveSingleItem().QualityStatusCode.ShouldBe(BadNoCommunication);
}
/// <summary>
/// Deleting one subscription while another stays tracked never left the server empty, so a BadNoSubscription
/// can only mean the survivor is gone — the window must not open.
/// </summary>
[Fact]
public async Task Deleting_a_subscription_that_was_not_the_last_does_not_open_the_window()
{
var (drv, _, seen) = NewConnectedDriver();
await using var _ = drv;
drv.AddAlarmSubscriptionForTest([AlarmRef]);
var data = drv.AddSubscriptionForTest([SpeedRef]);
await drv.UnsubscribeAsync(data, TestContext.Current.CancellationToken);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeDeclaredLost(drv, seen, "the alarm subscription outlived the delete, so the server should still hold it");
}
[Fact]
public async Task A_BadNoSubscription_naming_a_subscription_the_driver_no_longer_tracks_is_not_a_loss()
{
var (drv, clock, seen) = NewConnectedDriver();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
drv.AddAlarmSubscriptionForTest([AlarmRef]);
clock.Advance(TimeSpan.FromMinutes(1)); // well past the grace: attribution is by id, not by time
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription, subscriptionId: 4242);
ShouldBeUntouched(drv, seen, "the error names a subscription the driver does not track");
}
/// <summary>A released session starts with no window: the stamp of the previous session's delete is gone.</summary>
[Fact]
public async Task The_window_does_not_survive_a_session_release()
{
var (drv, clock, seen) = NewConnectedDriver();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
await drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
var session = new Mock<ISession>(MockBehavior.Loose);
drv.SetSessionForTest(session.Object);
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null));
drv.AddAlarmSubscriptionForTest([AlarmRef]);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
ShouldBeDeclaredLost(drv, seen, "the delete belonged to the released session");
}
}
/// <summary>
/// Gitea #764 residual: the server's DeleteSubscriptions can be slow (&gt; <see cref="OpcUaClientDriver.StaleNoSubscriptionGrace"/>).
/// The server answers the stale publish only once the delete is done, so the grace runs from the delete's
/// COMPLETION, not its start.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientSlowDeleteTests
{
private const string SpeedRef = "Plant/dev/Speed";
private const string AlarmRef = "Plant/dev/TempHi";
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 (OpcUaClientDriver Driver, ManualClock Clock, List<DriverConnectionLossEventArgs> Lost,
TaskCompletionSource Gate, TaskCompletionSource Started) New()
{
RawTagEntry[] tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = tags }, "opcua-764");
var session = new Mock<ISession>(MockBehavior.Loose);
session.SetupGet(s => s.MessageContext).Returns(ServiceMessageContext.Create(SdkTelemetry.Context));
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var clock = new ManualClock(new DateTimeOffset(2026, 11, 10, 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));
var lost = new List<DriverConnectionLossEventArgs>();
drv.ConnectionLost += (_, e) => lost.Add(e);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.DeleteSubscriptionAsync = async (_, _) => { started.TrySetResult(); await gate.Task; };
return (drv, clock, lost, gate, started);
}
[Fact]
public async Task A_slow_data_delete_still_attributes_the_stale_error_to_the_delete()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddSubscriptionForTest([SpeedRef]);
var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30)); // the delete takes far longer than the grace
gate.SetResult();
await unsub;
drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(TimeSpan.FromMilliseconds(850));
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_slow_alarm_delete_still_attributes_the_stale_error_to_the_delete()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddAlarmSubscriptionForTest([AlarmRef]);
var unsub = drv.UnsubscribeAlarmsAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30));
gate.SetResult();
await unsub;
drv.AddAlarmSubscriptionForTest([AlarmRef]);
clock.Advance(TimeSpan.FromMilliseconds(850));
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldBeEmpty("the grace runs from the delete's completion, not its start");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Positive control: a slow delete does not extend the window beyond grace after completion.</summary>
[Fact]
public async Task After_a_slow_delete_completes_the_grace_still_expires()
{
var (drv, clock, lost, gate, started) = New();
await using var _ = drv;
var first = drv.AddSubscriptionForTest([SpeedRef]);
var unsub = drv.UnsubscribeAsync(first, TestContext.Current.CancellationToken);
await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(30));
gate.SetResult();
await unsub;
drv.AddSubscriptionForTest([SpeedRef]);
clock.Advance(OpcUaClientDriver.StaleNoSubscriptionGrace);
drv.RaisePublishErrorForTest(StatusCodes.BadNoSubscription);
lost.ShouldHaveSingleItem().Reason.ShouldContain("subscription lost");
}
}