fix(opcuaclient): unresolvable acks warn instead of tripping the ack breaker; shutdown holds subscription management (review r1 N-1, N-2, N-3)
- 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
This commit is contained in:
@@ -1234,6 +1234,34 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
budget.CancelAfter(TeardownBudget);
|
||||
|
||||
// r1 N-2: subscription management is held off for the whole teardown. A subscribe already in flight finishes
|
||||
// first (bounded by the budget) and is enumerated below; one that arrives during the delete phase waits and
|
||||
// then finds the session released, instead of registering a subscription on the closing session after the
|
||||
// registry was enumerated. A wait the budget cut short proceeds without the gate (best effort).
|
||||
var holdsSubscriptionGate = false;
|
||||
try
|
||||
{
|
||||
await _subscriptionGate.WaitAsync(budget.Token).ConfigureAwait(false);
|
||||
holdsSubscriptionGate = true;
|
||||
}
|
||||
catch (OperationCanceledException) { /* budget spent on an in-flight subscribe — tear down regardless */ }
|
||||
catch (ObjectDisposedException) { /* disposed concurrently — tear down regardless */ }
|
||||
try
|
||||
{
|
||||
await ShutdownCoreAsync(generation, budget).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (holdsSubscriptionGate)
|
||||
{
|
||||
try { _subscriptionGate.Release(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShutdownCoreAsync(long generation, CancellationTokenSource budget)
|
||||
{
|
||||
|
||||
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
|
||||
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
|
||||
// whether or not the wire-side delete succeeds since the local handles are useless
|
||||
@@ -1287,16 +1315,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
{
|
||||
_namespaceMap = null;
|
||||
_connectedEndpointUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Detach the cert-validation handler so the (potentially process-shared)
|
||||
// CertificateValidator doesn't hold a delegate to a shutting-down driver.
|
||||
if (_certValidationHandler is not null && _certValidatorRef is not null)
|
||||
{
|
||||
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
|
||||
_certValidationHandler = null;
|
||||
_certValidatorRef = null;
|
||||
// Detach the cert-validation handler so the (potentially process-shared) CertificateValidator doesn't
|
||||
// hold a delegate to a shutting-down driver — generation-checked like the map (r1 N-3): a newer init
|
||||
// that installed its own handler meanwhile keeps it.
|
||||
DetachCertValidation(_certValidatorRef, _certValidationHandler);
|
||||
_certValidationHandler = null;
|
||||
_certValidatorRef = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _lifecycleGeneration) == generation) TransitionTo(HostState.Unknown);
|
||||
@@ -2556,12 +2582,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
if (callRequests.Count == 0)
|
||||
{
|
||||
// Review m3: nothing resolved to a condition node, so nothing was sent — that is not an applied ack.
|
||||
// Review m3 / r1 N-1: nothing resolved to a condition node, so nothing was sent — say so at Warning. NOT
|
||||
// thrown: this is a deterministic per-ack refusal, and a throw would count toward the host's
|
||||
// AlarmAcknowledge circuit breaker (5 failures), so an operator re-acking an unresolvable condition
|
||||
// would block every genuine ack on this driver. Only a no-reply transport failure throws (F2 owns a
|
||||
// per-ack result surface).
|
||||
_logger.LogWarning(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': AcknowledgeAsync resolved none of {Count} condition id(s) " +
|
||||
"against the live session; nothing was sent upstream.", _driverInstanceId, acknowledgements.Count);
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown,
|
||||
"None of the acknowledged condition ids resolved to an upstream condition node.");
|
||||
"against the live session; nothing was sent upstream and the acknowledgement was NOT applied.",
|
||||
_driverInstanceId, acknowledgements.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user