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.