290 lines
13 KiB
C#
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 (> <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");
|
|
}
|
|
}
|