0217d36cf4
v2-ci / unit-tests (push) Has been cancelled
HandlePublishError treated any anonymous BadNoSubscription (subscription id 0) as loss of a tracked subscription whenever anything was tracked. A publish the SDK queued while the session briefly held no subscription (the driver had just deleted its last one) is answered BadNoSubscription and can be handled after the replacement is registered, so a routine unsubscribe->resubscribe declared the new subscription lost: Degraded health, a BadNoCommunication quality-only annotation on every alarm condition and ConnectionLost (host re-initialise). That annotation is the extra Unspecified event the #728 ConditionRefresh wire test saw. - A BadNoSubscription that names a subscription id now counts only if the id is tracked (same rule as BadSubscriptionIdInvalid). - An anonymous BadNoSubscription within StaleNoSubscriptionGrace (5 s) of a delete that left the driver tracking nothing is ignored (Debug log). The window is stamped on removal and restarted when the delete completes; failed-subscribe removes stamp it too; it resets with the session. A delete that leaves another subscription tracked opens no window. A genuine loss inside the window is still caught by the PublishingStopped heuristic; only the immediate ConnectionLost is forgone. - OpcUaClientStaleNoSubscriptionTests: stale error after alarm and data resubscribe (fail without the fix), id-attributed stale error, plus genuine-loss controls (after the grace, no recent delete, non-last delete, session release), each mutation-checked. - The ConditionRefresh wire test now records Kind, AlarmType, message, severity, quality, restatement and timestamp of every post-resubscribe event on failure. Wire test locally: 1/10 passing before (all 9 failures the 0x80310000 ConditionType quality-only annotation), 12/12 after. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
187 lines
8.6 KiB
C#
187 lines
8.6 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(new ServiceMessageContext(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");
|
|
}
|
|
}
|