fix(#662 review): back off alarm retries, contain failed OpcUaClient alarm subscribes

- Reconcile retries of a failing alarm subscribe back off (skip 2^(n-1)-1
  ticks, cap 7 => >= every 4 min at the 30 s tick) so a hanging subscribe
  cannot hold the mailbox every tick; the stack is logged once per streak.
- The unreachable handler re-attach logs at Warning (it only fires on a
  regression).
- OpcUaClient: a failed SubscribeAlarmsAsync detaches its notification
  handler and bounds the session cleanup to 5 s under _gate;
  OnEventNotification drops events from subscriptions not registered in
  _alarmSubscriptions, so a stray that survives cleanup cannot deliver.
- The retry test also covers 3 failures (crosses the backoff); its tick
  positive control documents its dependence on the stub's changing health.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 07:35:50 -04:00
parent 5067509dd4
commit 28ca04e466
3 changed files with 75 additions and 17 deletions
@@ -1701,6 +1701,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
AttributeId = Attributes.Value,
});
// Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it (#662).
MonitoredItem? eventItem = null;
MonitoredItemNotificationEventHandler? notifHandler = null;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
@@ -1716,9 +1720,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
catch
{
// #662: the host now retries a failed alarm subscribe on every reconcile tick, so a half-built
// subscription left on the session would accumulate one per retry for the length of an outage.
try { await session.RemoveSubscriptionAsync(subscription, CancellationToken.None).ConfigureAwait(false); }
// #662: the host retries a failed alarm subscribe from its reconcile, so a half-built subscription
// left on the session would accumulate one per retry for the length of an outage. Detach the
// handler first — the remove is best-effort and can itself fail against a broken server — and
// bound it, since _gate is held. OnEventNotification also drops anything from an unregistered id.
if (eventItem is not null && notifHandler is not null)
{
try { eventItem.Notification -= notifHandler; }
catch { /* best-effort */ }
}
try
{
using var removeCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await session.RemoveSubscriptionAsync(subscription, removeCts.Token).ConfigureAwait(false);
}
catch { /* best-effort — the session may be the thing that failed */ }
throw;
}
@@ -1736,7 +1751,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
var eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = "Server/Events",
StartNodeId = ObjectIds.Server,
@@ -1753,7 +1768,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// can `-=` it later. 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) =>
notifHandler = (mi, args) =>
OnEventNotification(handle, sourceFilter, mi, args);
eventItem.Notification += notifHandler;
subscription.AddItem(eventItem);
@@ -1898,6 +1913,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
MonitoredItemNotificationEventArgs args)
{
if (args.NotificationValue is not EventFieldList efl) return;
// #662 review: only a subscription SubscribeAlarmsAsync completed (and nothing has since released) may
// deliver. A half-built one whose cleanup failed against a broken server can survive on the session and
// be transferred by an SDK reconnect; without this it would feed RouteAlarmEvent alongside the real one.
if (!_alarmSubscriptions.ContainsKey(handle.Id)) return;
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
}