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