c39bcd6e24
- 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
179 lines
8.7 KiB
C#
179 lines
8.7 KiB
C#
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-8 — <c>AcknowledgeAsync</c> swallowed every non-cancellation transport failure, so
|
|
/// the host logged the Part 9 ack as applied while upstream still showed the condition unacknowledged. A
|
|
/// transport failure (no reply) now propagates with its status; a reply — even one carrying per-ack refusals —
|
|
/// still returns normally (that is the server answering, logged per ack).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientAcknowledgeFailureTests
|
|
{
|
|
private const string Condition = "ns=2;s=Pump17";
|
|
|
|
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 AlarmAcknowledgeRequest[] Ack() => [new(Condition, Condition, "operator ack")];
|
|
|
|
/// <summary>A transport failure on the Acknowledge call reaches the caller with the transport status.</summary>
|
|
[Fact]
|
|
public async Task A_transport_failure_propagates_with_its_status()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
|
var session = NewSession();
|
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
|
drv.SetSessionForTest(session.Object);
|
|
|
|
var ex = await Should.ThrowAsync<ServiceResultException>(() =>
|
|
drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCodes.BadConnectionClosed);
|
|
}
|
|
|
|
/// <summary>Positive control: a reply returns normally and the call reached the wire.</summary>
|
|
[Fact]
|
|
public async Task Positive_control_a_reply_returns_normally()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
|
var session = NewSession();
|
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new CallResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [new CallMethodResult { StatusCode = StatusCodes.BadConditionBranchAlreadyAcked }],
|
|
DiagnosticInfos = [],
|
|
});
|
|
drv.SetSessionForTest(session.Object);
|
|
|
|
await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken);
|
|
|
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
/// <summary>A session lost while the ack queued on the gate fails the ack rather than reporting it applied.</summary>
|
|
[Fact]
|
|
public async Task A_session_lost_while_queued_fails_the_ack()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-lost");
|
|
drv.SetSessionForTest(NewSession().Object);
|
|
|
|
await drv.Gate.WaitAsync(ct);
|
|
var call = drv.AcknowledgeAsync(Ack(), ct);
|
|
drv.SetSessionForTest(null);
|
|
drv.Gate.Release();
|
|
|
|
await Should.ThrowAsync<ServiceResultException>(() => call);
|
|
}
|
|
|
|
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<OpcUaClientDriver>
|
|
{
|
|
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = [];
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
|
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
}
|
|
|
|
private static AlarmAcknowledgeRequest[] Unresolvable() =>
|
|
[new("nsu=urn:not-in-table;s=X", "nsu=urn:not-in-table;s=X", "ack")];
|
|
|
|
/// <summary>
|
|
/// Review m3 / r1 N-1: an ack none of whose condition ids resolves sends nothing upstream and says so at Warning —
|
|
/// but does NOT throw (a deterministic per-ack refusal must not count toward the AlarmAcknowledge breaker).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_ack_with_no_resolvable_condition_warns_and_does_not_throw()
|
|
{
|
|
var logger = new ListLogger();
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m3", logger);
|
|
var session = NewSession();
|
|
drv.SetSessionForTest(session.Object);
|
|
|
|
await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken);
|
|
|
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
lock (logger.Entries)
|
|
logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// r1 N-1 through the host's real resilience pipeline: six unresolvable acks (more than the AlarmAcknowledge
|
|
/// breaker's threshold of five) must not open the breaker — a genuine ack that follows is still delivered
|
|
/// upstream (the positive control).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Unresolvable_acks_do_not_open_the_ack_breaker_for_real_acks()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
|
var session = NewSession();
|
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new CallResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = [new CallMethodResult { StatusCode = StatusCodes.Good }],
|
|
DiagnosticInfos = [],
|
|
});
|
|
drv.SetSessionForTest(session.Object);
|
|
var options = new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResilienceOptions { Tier = DriverTier.A };
|
|
var invoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker(
|
|
new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder(), "opcua-n1", () => options, "OpcUaClient");
|
|
|
|
// Failures are tolerated here on purpose (the host logs and drops them): what is asserted is that the six do
|
|
// not trip the breaker — a driver that THREW for them would fail the genuine ack below with an open circuit.
|
|
for (var i = 0; i < 6; i++)
|
|
{
|
|
try
|
|
{
|
|
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
|
async c => await drv.AcknowledgeAsync(Unresolvable(), c), ct);
|
|
}
|
|
catch (ServiceResultException) { }
|
|
}
|
|
|
|
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
|
async c => await drv.AcknowledgeAsync(Ack(), c), ct);
|
|
|
|
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
|
Times.Once, "the genuine ack must still reach the server");
|
|
}
|
|
|
|
/// <summary>Review m4: an ack with no reply is a comms fault like a read or write — Degraded, stamp preserved.</summary>
|
|
[Fact]
|
|
public async Task An_ack_transport_failure_degrades_and_preserves_the_stamp()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m4");
|
|
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
|
var session = NewSession();
|
|
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
|
drv.SetSessionForTest(session.Object);
|
|
|
|
await Should.ThrowAsync<ServiceResultException>(() => drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(stamp);
|
|
}
|
|
}
|