fix(opcuaclient): remove a half-built data subscription when its create throws (R06:O-1)

SubscribeAsync left the SDK Subscription on the session (server-side when
CreateAsync succeeded, item handlers attached, tracked by nothing) when
CreateAsync/CreateItemsAsync threw, so the host's per-tick reconcile retry
accumulated one live subscription per retry. Mirror the alarm path: detach
the item handlers, bounded RemoveSubscriptionAsync, rethrow.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:12:03 -04:00
parent 0d49eaf77b
commit 4d0d5d229d
2 changed files with 113 additions and 6 deletions
@@ -1787,13 +1787,39 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
?? throw new InvalidOperationException(
"OpcUaClient session was lost before the subscription could be created.");
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
// Track each (MonitoredItem, handler) pair so UnsubscribeAsync / ShutdownAsync
// can detach the Notification delegate before disposing the session. The lambda
// captures `handle`, so we must hold the exact delegate instance returned by
// `+=` to be able to remove it.
var itemHandlers = new List<MonitoredItemNotificationHandle>();
try
{
await CreateDataItemsAsync(session, itemHandlers).ConfigureAwait(false);
}
catch
{
// R06:O-1 — the host's reconcile retries a failed subscribe every health tick, so a half-built
// subscription left on the session (created server-side when CreateAsync succeeded, its item
// handlers still attached, tracked by nothing) would accumulate one live, publishing subscription
// per retry. Mirror the alarm path: detach, bounded remove, rethrow.
DetachNotificationHandlers(itemHandlers);
try
{
using var removeCts = new CancellationTokenSource(SessionCloseTimeout);
await session.RemoveSubscriptionAsync(subscription, removeCts.Token).ConfigureAwait(false);
}
catch { /* best-effort — the session may be the thing that failed */ }
throw;
}
_subscriptions[id] = new RemoteSubscription(subscription, handle, itemHandlers);
}
finally { _gate.Release(); }
return handle;
async Task CreateDataItemsAsync(ISession session, List<MonitoredItemNotificationHandle> itemHandlers)
{
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
foreach (var fullRef in fullReferences)
{
if (!TryResolveTagNode(session, fullRef, out var nodeId)) continue;
@@ -1821,11 +1847,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_subscriptions[id] = new RemoteSubscription(subscription, handle, itemHandlers);
}
finally { _gate.Release(); }
return handle;
}
/// <inheritdoc />