6c3a754be9
- Every InitializeAsync/ShutdownAsync bumps a lifecycle generation. The session, namespace map and endpoint are installed under _probeLock only while the init's generation is current, so an init the host abandoned that finishes after the next shutdown discards its session instead of resurrecting it. Every health write (lifecycle, keep-alive, publish-error, read path) is generation-stamped, so a late writer can no longer overwrite newer state. - O-2: the init catch closes and disposes the LOCAL session it opened (it closed the still-null Session property, orphaning an activated session + keep-alive timer). - O-9: ShutdownAsync spends ONE budget (4 s, inside the host's 5 s PostStop budget) on wire calls: every subscription delete concurrently, then the session close with what remains; waits are abandoned when the budget fires even if the SDK ignores the token. It used to be 5 s per subscription, serially. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
221 lines
10 KiB
C#
221 lines
10 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;
|
||
}
|
||
}
|