Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs
T
Joseph Doherty c39bcd6e24 fix(opcuaclient): unresolvable acks warn instead of tripping the ack breaker; shutdown holds subscription management (review r1 N-1, N-2, N-3)
- N-1 (Important): m3's BadNodeIdUnknown throw for an ack with no resolvable
  condition counted toward the host's AlarmAcknowledge breaker (threshold 5), so
  re-acks of one unresolvable condition blocked every genuine ack. It now logs a
  Warning and returns; only a no-reply transport failure throws. Test through the
  real CapabilityInvoker: six unresolvable acks, then a genuine ack that is still
  delivered (red with the throw restored).
- N-2: ShutdownAsync holds _subscriptionGate for the whole teardown (in-flight
  subscribes finish first, bounded by the budget), so a subscribe arriving during
  the delete phase finds the session released instead of creating a subscription
  on the closing session. Red-tested.
- N-3: the shutdown-side cert-validation detach is generation-checked under
  _probeLock with the map / endpoint clear.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:58:37 -04:00

261 lines
12 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.Diagnostics;
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>
/// Review 2026-10-21 R06:O-2 / O-9 and the lifecycle-generation contract: an initialise that throws after its
/// session activated closes that LOCAL session; an initialise the host abandoned that finishes after the next
/// shutdown discards its session instead of installing it; a read that outlives a shutdown does not overwrite the
/// shutdown's health; and the whole shutdown spends one bounded budget on wire calls, issued concurrently.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientLifecycleGenerationTests
{
private const string SpeedRef = "Plant/dev/Speed";
private static readonly IReadOnlyList<RawTagEntry> Tags =
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
private static OpcUaClientDriverOptions Options() =>
new() { EndpointUrl = "opc.tcp://127.0.0.1:1", RawTags = Tags };
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 ReadResponse GoodRead(ReadValueIdCollection nodes) => new()
{
ResponseHeader = new ResponseHeader(),
Results = [.. nodes.Select(_ => new DataValue(new Variant(1), 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);
}
/// <summary>O-2: a throw after the session opened (here: building the namespace map) closes + disposes it.</summary>
[Fact]
public async Task An_init_that_throws_after_the_session_opened_closes_and_disposes_that_session()
{
using var drv = new OpcUaClientDriver(Options(), "opcua-o2");
var session = NewSession();
session.SetupGet(s => s.NamespaceUris).Throws(new InvalidOperationException("namespace table unavailable"));
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
await Should.ThrowAsync<InvalidOperationException>(() =>
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
session.Verify(s => s.CloseAsync(It.IsAny<int>(), true, It.IsAny<CancellationToken>()), Times.Once);
session.Verify(s => s.Dispose(), Times.Once, "the activated session must not be orphaned until GC");
drv.Session.ShouldBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Positive control for the init seam: an uninterrupted init installs the session and goes Healthy.</summary>
[Fact]
public async Task Positive_control_an_uninterrupted_init_installs_its_session()
{
using var drv = new OpcUaClientDriver(Options(), "opcua-gen-ok");
var session = NewSession();
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.Session.ShouldBeSameAs(session.Object);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
session.Verify(s => s.Dispose(), Times.Never);
}
/// <summary>
/// An init abandoned by the host whose connect completes AFTER the next ShutdownAsync must not install its
/// session (it would be live with nothing to ever close it) nor overwrite the shutdown's health.
/// </summary>
[Fact]
public async Task An_init_superseded_by_a_shutdown_discards_its_session_and_leaves_the_shutdown_health()
{
var drv = new OpcUaClientDriver(Options(), "opcua-gen-superseded");
using var _ = drv;
var session = NewSession();
var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.OpenSessionForTest = async (_, _) =>
{
opened.TrySetResult();
await release.Task;
return session.Object;
};
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await opened.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
release.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => init);
drv.Session.ShouldBeNull("a superseded init must not resurrect a session behind the shutdown");
session.Verify(s => s.Dispose(), Times.Once);
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health");
}
/// <summary>A read whose response arrives after a shutdown does not write Healthy over the shutdown.</summary>
[Fact]
public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health()
{
var (drv, session, respond) = ConnectedDriverWithParkedRead();
using var _ = drv;
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
respond.Single().SetResult();
await read;
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Positive control: the same parked read with no shutdown in between writes Healthy + a new stamp.</summary>
[Fact]
public async Task Positive_control_a_read_with_no_intervening_shutdown_writes_healthy()
{
var (drv, _, respond) = ConnectedDriverWithParkedRead();
using var __ = drv;
drv.SetHealthForTest(new DriverHealth(DriverState.Degraded, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), "x"));
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
respond.Single().SetResult();
await read;
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().Year.ShouldBeGreaterThan(2020);
}
private static (OpcUaClientDriver Driver, Mock<ISession> Session, System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource> Respond)
ConnectedDriverWithParkedRead()
{
var drv = new OpcUaClientDriver(Options(), "opcua-gen-read");
var session = NewSession();
var respond = new System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource>();
session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
{
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
respond.Enqueue(gate);
await gate.Task;
return GoodRead(nodes);
});
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
return (drv, session, respond);
}
/// <summary>
/// O-9: against a half-dead upstream every subscription delete and the session close hang. They are issued
/// concurrently under ONE budget, so the shutdown ends near the budget rather than N × 5 s.
/// </summary>
[Fact]
public async Task Shutdown_spends_one_budget_on_concurrent_subscription_deletes_and_the_close()
{
using var drv = new OpcUaClientDriver(Options(), "opcua-o9");
var session = NewSession();
// A close that ignores its token entirely — only the driver's own abandonment can bound it.
session.Setup(s => s.CloseAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns(new TaskCompletionSource<StatusCode>().Task);
drv.SetSessionForTest(session.Object);
drv.TeardownBudget = TimeSpan.FromSeconds(1);
var inFlight = 0;
var maxInFlight = 0;
drv.DeleteSubscriptionAsync = async (_, ct) =>
{
var now = Interlocked.Increment(ref inFlight);
InterlockedMax(ref maxInFlight, now);
try { await Task.Delay(Timeout.Infinite, ct); }
finally { Interlocked.Decrement(ref inFlight); }
};
for (var i = 0; i < 4; i++) drv.AddSubscriptionForTest([SpeedRef]);
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
sw.Stop();
maxInFlight.ShouldBe(4, "the four deletes must be in flight together, not one after another");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3.5),
"one 1 s budget bounds the whole teardown; serial deletes alone would take 4 × the budget");
session.Verify(s => s.Dispose(), Times.Once, "the session is still disposed when the close was abandoned");
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
private static void InterlockedMax(ref int target, int value)
{
int current;
while ((current = Volatile.Read(ref target)) < value)
if (Interlocked.CompareExchange(ref target, value, current) == current) return;
}
/// <summary>
/// r1 N-2: a subscribe that arrives while shutdown is in its delete phase must not create a subscription on the
/// closing session (it would register after the registry was enumerated and outlive the shutdown). It waits for
/// the teardown and then finds no session. Positive control: before the shutdown the same subscribe reaches the
/// session.
/// </summary>
[Fact]
public async Task A_subscribe_during_the_shutdown_delete_phase_never_touches_the_closing_session()
{
using var drv = new OpcUaClientDriver(Options(), "opcua-n2");
var session = NewSession();
drv.SetSessionForTest(session.Object);
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
// Positive control: an ordinary subscribe does reach the session (the mock then fails the create).
await Should.ThrowAsync<Exception>(() =>
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once);
var deleting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseDelete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.DeleteSubscriptionAsync = async (_, _) =>
{
deleting.TrySetResult();
await releaseDelete.Task;
};
drv.AddSubscriptionForTest([SpeedRef]);
var shutdown = drv.ShutdownAsync(TestContext.Current.CancellationToken);
await deleting.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
var lateSubscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
releaseDelete.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await Should.ThrowAsync<Exception>(() => lateSubscribe.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once,
"the late subscribe must not have created anything on the closing session");
}
}