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.