fix(driver-opcuaclient): resolve Low code-review findings (Driver.OpcUaClient-011,014)

- Driver.OpcUaClient-011: rewrote the ValueRank comment with the OPC UA
  Part 3 constants and an explicit scalar/array boundary at
  valueRank >= 0.
- Driver.OpcUaClient-014: track every MonitoredItem.Notification handler
  in a MonitoredItemNotificationHandle record; UnsubscribeAsync /
  UnsubscribeAlarmsAsync / ShutdownAsync detach the handler before
  Subscription.DeleteAsync so the SDK's invocation list no longer keeps
  the driver alive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:17:55 -04:00
parent d5322b0f9a
commit 42aa82de29
3 changed files with 239 additions and 17 deletions

View File

@@ -494,9 +494,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// 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
// after close anyway.
// after close anyway. Before deleting each subscription we detach the Notification
// handlers we attached at subscribe time so the SDK's invocation list no longer
// holds the driver instance through the closure (Driver.OpcUaClient-014).
foreach (var rs in _subscriptions.Values)
{
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
@@ -504,6 +507,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
foreach (var ras in _alarmSubscriptions.Values)
{
try { ras.EventItem.Notification -= ras.Handler; }
catch { /* best-effort */ }
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
@@ -1005,7 +1010,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
? MapUpstreamDataType(dtId)
: DriverDataType.Int32;
var valueRank = StatusCode.IsGood(valueRankDv.StatusCode) && valueRankDv.Value is int vr ? vr : -1;
var isArray = valueRank >= 0; // -1 = scalar; 1+ = array dimensions; 0 = one-dimensional array
// OPC UA Part 3 ValueRank constants: -3 = ScalarOrOneDimension, -2 = Any,
// -1 = Scalar, 0 = OneOrMoreDimensions, 1 = OneDimension, >1 = N specific dimensions.
// Deliberate choice: treat anything >= 0 as an array (the spec guarantees -3/-2/-1
// are the only negative values, and any non-negative rank denotes at least one
// array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated
// as scalar — array-of-one is exposed as scalar to the local address space until
// the upstream variable carries a concrete dimensioned rank.
var isArray = valueRank >= 0;
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
var securityClass = MapAccessLevelToSecurityClass(access);
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
@@ -1110,6 +1122,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
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
// (Driver.OpcUaClient-014). 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>();
foreach (var fullRef in fullReferences)
{
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
@@ -1128,12 +1145,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
Handle = fullRef,
};
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
MonitoredItemNotificationEventHandler notifHandler = (mi, args) =>
OnMonitoredItemNotification(handle, mi, args);
item.Notification += notifHandler;
itemHandlers.Add(new MonitoredItemNotificationHandle(item, notifHandler));
subscription.AddItem(item);
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_subscriptions[id] = new RemoteSubscription(subscription, handle);
_subscriptions[id] = new RemoteSubscription(subscription, handle, itemHandlers);
}
finally { _gate.Release(); }
@@ -1148,12 +1168,28 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Detach Notification handlers BEFORE deleting the subscription so the SDK's
// MonitoredItem.Notification multicast invocation list no longer holds a
// closure that captures the driver instance (Driver.OpcUaClient-014). The
// delegate stored on RemoteSubscription is the exact instance that was added,
// so `-=` removes it cleanly.
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — the subscription may already be gone on reconnect */ }
}
finally { _gate.Release(); }
}
private static void DetachNotificationHandlers(IReadOnlyList<MonitoredItemNotificationHandle> items)
{
for (var i = 0; i < items.Count; i++)
{
var pair = items[i];
try { pair.Item.Notification -= pair.Handler; }
catch { /* best-effort — SDK may have already cleared its invocation list on session loss */ }
}
}
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
{
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
@@ -1170,7 +1206,28 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
}
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
/// <summary>
/// Live data-change subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
/// the local handle, and the per-MonitoredItem (item, handler) pairs so
/// <see cref="UnsubscribeAsync"/> / <see cref="ShutdownAsync"/> can detach the
/// Notification delegates before the SDK disposes the subscription
/// (Driver.OpcUaClient-014).
/// </summary>
private sealed record RemoteSubscription(
Subscription Subscription,
OpcUaSubscriptionHandle Handle,
IReadOnlyList<MonitoredItemNotificationHandle> ItemHandlers);
/// <summary>
/// One (MonitoredItem, handler-delegate-instance) pair captured at subscribe time so
/// the same delegate instance can be `-=` removed at unsubscribe time. The lambda
/// captures the local <c>OpcUaSubscriptionHandle</c>, which is what makes detach
/// necessary — without it the SDK's multicast invocation list holds the driver
/// through the closure until the session itself is disposed.
/// </summary>
private sealed record MonitoredItemNotificationHandle(
MonitoredItem Item,
MonitoredItemNotificationEventHandler Handler);
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
{
@@ -1259,11 +1316,17 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
Handle = handle,
};
eventItem.Notification += (mi, args) => OnEventNotification(handle, sourceFilter, mi, args);
// Capture the exact delegate instance so UnsubscribeAlarmsAsync / ShutdownAsync
// can `-=` it later (Driver.OpcUaClient-014). The lambda captures `handle` and
// `sourceFilter`, so without the explicit detach the SDK's invocation list keeps
// the driver instance alive until the session itself is disposed.
MonitoredItemNotificationEventHandler notifHandler = (mi, args) =>
OnEventNotification(handle, sourceFilter, mi, args);
eventItem.Notification += notifHandler;
subscription.AddItem(eventItem);
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle);
_alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle, eventItem, notifHandler);
}
finally { _gate.Release(); }
@@ -1278,6 +1341,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Detach the Notification handler before deleting the subscription so the SDK's
// multicast invocation list no longer holds the driver instance through the
// closure (Driver.OpcUaClient-014).
try { rs.EventItem.Notification -= rs.Handler; }
catch { /* best-effort */ }
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — session may already be gone across a reconnect */ }
}
@@ -1405,7 +1473,17 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_ => AlarmSeverity.Critical,
};
private sealed record RemoteAlarmSubscription(Subscription Subscription, OpcUaAlarmSubscriptionHandle Handle);
/// <summary>
/// Live alarm-event subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
/// the local handle, the single event-MonitoredItem (`Server/Events`), and the exact
/// handler delegate instance so unsubscribe / shutdown can detach the Notification
/// event before the SDK disposes the subscription (Driver.OpcUaClient-014).
/// </summary>
private sealed record RemoteAlarmSubscription(
Subscription Subscription,
OpcUaAlarmSubscriptionHandle Handle,
MonitoredItem EventItem,
MonitoredItemNotificationEventHandler Handler);
private sealed record OpcUaAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{