fix(opcuaclient): an acknowledge with no reply fails with its transport status (R06:O-8)
AcknowledgeAsync swallowed every non-cancellation transport failure (and returned normally when the session was lost while the ack queued), so the host logged the Part 9 ack as applied while upstream still showed it unacknowledged. Both now throw (ServiceResultException with the transport status / BadNotConnected). A reply, including per-ack refusals, still returns normally and counts as the server answering. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -231,6 +231,12 @@ initialise (the AdminUI form drops them on the next save). Pinned by
|
||||
> forgotten when the subscribed set changes (the host rebuilt, recreating the nodes inactive). Index keys are server-stable (`nsu=`), so a namespace reorder across a re-initialise keeps
|
||||
> matching. docker-dev seeds an opc-plc driver (`MAIN-opcua`); the alarm tag is
|
||||
> created only when `@OpcPlcAlarmSourceNodeId` is set in `docker-dev/seed/seed-clusters.sql`.
|
||||
>
|
||||
> **An ack that never got a reply fails (review 2026-10-21 O-8).** A transport failure on the `Acknowledge` call
|
||||
> (closed channel, faulted session) — or a session lost while the ack queued — now throws with its status
|
||||
> (`ServiceResultException`, e.g. `BadConnectionClosed` / `BadNotConnected`), so the host logs the ack as failed
|
||||
> instead of applied. A *reply* still returns normally, including per-ack refusals such as
|
||||
> `BadConditionBranchAlreadyAcked` (logged per ack): a refusal is the server answering.
|
||||
|
||||
> This driver does **not** implement `IRediscoverable` — there is no
|
||||
> push-driven rediscovery signal from a remote OPC UA server in this driver.
|
||||
|
||||
@@ -2496,9 +2496,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// Re-read Session inside the critical section: a reconnect completing while we were
|
||||
// blocked on _gate has already swapped in the new session via a re-initialise.
|
||||
// ConditionId parsing is namespace-relative, so it must use the current session's
|
||||
// namespace table too.
|
||||
var session = Session;
|
||||
if (session is null) return;
|
||||
// namespace table too. A session lost while this ack queued is a failure, not a silent success: the
|
||||
// host would otherwise log the ack as applied (R06:O-8).
|
||||
var generation = Volatile.Read(ref _lifecycleGeneration);
|
||||
var session = Session
|
||||
?? throw new ServiceResultException(StatusCodes.BadNotConnected,
|
||||
"OpcUaClient session was lost before the acknowledgement could be sent.");
|
||||
|
||||
// OPC UA A&C: call the AcknowledgeableConditionType.Acknowledge method on each
|
||||
// condition node with EventId + Comment arguments. CallAsync accepts a batch —
|
||||
@@ -2543,6 +2546,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
requestHeader: null,
|
||||
methodsToCall: callRequests,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
// A response — whatever the per-ack statuses say — is the server answering.
|
||||
RecordDeviceAnswered(generation, Clock.GetUtcNow().UtcDateTime);
|
||||
|
||||
// Inspect per-ack results — the upstream server can reject individual acks
|
||||
// (BadConditionAlreadyAcked, BadNodeIdUnknown, BadUserAccessDenied) even when
|
||||
@@ -2575,11 +2580,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log genuine transport failures rather than swallowing them silently.
|
||||
// R06:O-8 — a transport failure means NO reply: the acknowledgement was not applied upstream, so the
|
||||
// caller must see it (with the transport status, e.g. BadConnectionClosed) instead of a success.
|
||||
_logger.LogWarning(ex,
|
||||
"OpcUaClientDriver '{DriverInstanceId}': AcknowledgeAsync failed; " +
|
||||
"acknowledgements may not have been applied.",
|
||||
"acknowledgements were not applied.",
|
||||
_driverInstanceId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user