Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientLifecycleGenerationTests.cs
T
Joseph Doherty d64c77b62d fix(opcuaclient): shutdown cancels session work before the gate wait; dispose skips a redundant nested shutdown (review r2 R2-1, R2-2)
- R2-1: the ConditionRefresh sweeps (RefreshRequired, post-outage recovery) run
  under the session's work token, and ShutdownAsync cancels session work first
  thing - so a sweep holding the subscription gate against a stalling upstream no
  longer eats the whole teardown budget the deletes and close need.
- R2-2: DisposeAsync skips its nested ShutdownAsync when a shutdown already started
  for the current lifecycle generation (nothing initialised since), so PostStop's
  dispose does not re-wait the gate past its budget. Test: with the gate held, the
  shutdown waits (positive control) and the dispose does not wait again; red with
  the skip removed.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:08:44 -04:00

282 lines
14 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");
}
/// <summary>
/// r2 R2-2: PostStop runs ShutdownAsync then DisposeAsync. With subscription management contended (a subscribe
/// still holding the gate), the shutdown spends its budget waiting on the gate; the dispose that follows must not
/// re-run a nested shutdown and wait all over again. Positive control: the shutdown itself did wait.
/// </summary>
[Fact]
public async Task Dispose_after_a_shutdown_of_the_same_lifecycle_does_not_rewait_the_gate()
{
var drv = new OpcUaClientDriver(Options(), "opcua-r22") { TeardownBudget = TimeSpan.FromSeconds(3) };
drv.SetSessionForTest(NewSession().Object);
await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken); // a "subscribe" that never finishes
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(2), "positive control: the shutdown waited on the held gate");
sw.Restart();
await drv.DisposeAsync();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2), "the dispose must not wait the 3 s gate budget a second time");
}
}