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:
Joseph Doherty
2026-10-22 15:21:19 -04:00
parent 6c3a754be9
commit afafd483dd
3 changed files with 101 additions and 5 deletions
@@ -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(); }