From 151b7165af2dd6f03593aec031034d16b93b62a5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 06:14:44 -0400 Subject: [PATCH] docs(abcip,focas): document RetireAsync one-tick overlap residual + guard Dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups on the poll-loop collapse: (1) RetireAsync is fire-and- forget and does NOT guarantee zero overlap — the retired loop runs until its in-flight read+tick finish and it observes cancellation, so a device transition landing in that one-tick window can fire once on both loops (at most ONE duplicate raise/clear per reconnect, transient + self-correcting; upstream Part 9 conditions dedupe on ConditionId). Documented in both RetireAsync XML docs so it isn't mistaken for a zero-overlap guarantee. (2) wrap Cts.Dispose() so the fire-and-forget task has no theoretical unobserved-exception path. --- .../AbCipAlarmProjection.cs | 13 +++++++++++-- .../FocasAlarmProjection.cs | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs index d76f06c6..2bb58691 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -91,15 +91,24 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable /// /// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes - /// its CTS. Fire-and-forget from ; every await is wrapped so an + /// its CTS. Fire-and-forget from ; every step is wrapped so an /// unobserved exception can never escape (the loops already swallow their own). + /// + /// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight + /// read + tick complete and it observes cancellation at the next Task.Delay. If a device + /// transition lands in that one-tick window, both the old and new loops can fire it once — + /// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old + /// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on + /// ConditionId, so this is absorbed. Awaiting the retire before starting the new loop + /// would close the window at the cost of blocking the subscribe for one read latency. + /// /// /// The retired subscription whose loop must be cancelled + disposed. private static async Task RetireAsync(Subscription sub) { try { sub.Cts.Cancel(); } catch { } try { await sub.Loop.ConfigureAwait(false); } catch { } - sub.Cts.Dispose(); + try { sub.Cts.Dispose(); } catch { } } /// Unsubscribes from alarm events using the provided subscription handle. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs index a219578d..fb1d9218 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs @@ -84,8 +84,17 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable /// /// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes - /// its CTS. Fire-and-forget from ; every await is wrapped so an + /// its CTS. Fire-and-forget from ; every step is wrapped so an /// unobserved exception can never escape (the loops already swallow their own). + /// + /// This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight + /// read + tick complete and it observes cancellation at the next Task.Delay. If a device + /// transition lands in that one-tick window, both the old and new loops can fire it once — + /// i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old + /// loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on + /// ConditionId, so this is absorbed. Awaiting the retire before starting the new loop + /// would close the window at the cost of blocking the subscribe for one read latency. + /// /// /// The retired subscription whose loop must be cancelled + disposed. private async Task RetireAsync(Subscription sub) @@ -94,7 +103,8 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); } try { await sub.Loop.ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); } - sub.Cts.Dispose(); + try { sub.Cts.Dispose(); } + catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); } } /// Unsubscribes from an alarm subscription.