docs(abcip,focas): document RetireAsync one-tick overlap residual + guard Dispose
v2-ci / build (push) Failing after 2m47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

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.
This commit is contained in:
Joseph Doherty
2026-06-15 06:14:44 -04:00
parent 6ba59f9d4d
commit 151b7165af
2 changed files with 23 additions and 4 deletions
@@ -91,15 +91,24 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
/// <summary>
/// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every await is wrapped so an
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
/// unobserved exception can never escape (the loops already swallow their own).
/// <para>
/// 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 <c>Task.Delay</c>. 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
/// <c>ConditionId</c>, 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.
/// </para>
/// </summary>
/// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
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 { }
}
/// <summary>Unsubscribes from alarm events using the provided subscription handle.</summary>
@@ -84,8 +84,17 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
/// <summary>
/// Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every await is wrapped so an
/// its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
/// unobserved exception can never escape (the loops already swallow their own).
/// <para>
/// 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 <c>Task.Delay</c>. 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
/// <c>ConditionId</c>, 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.
/// </para>
/// </summary>
/// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
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"); }
}
/// <summary>Unsubscribes from an alarm subscription.</summary>