d64c77b62d
- 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
282 lines
14 KiB
C#
282 lines
14 KiB
C#
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");
|
||
}
|
||
}
|