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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user