fix(dashboard): generation-scoped idle gate in UnsubscribeAsync — no zero-subscriber pump survives

This commit is contained in:
Joseph Doherty
2026-08-15 20:43:17 -04:00
parent b0f5941e46
commit 756296886b
2 changed files with 109 additions and 5 deletions
@@ -14,8 +14,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <remarks>
/// <para>
/// The pump is idle-gated: it starts when the first subscriber arrives and is cancelled and
/// awaited when the last one leaves, so an unwatched gateway runs no timer and builds no
/// snapshots. Successive pumps are chained through <c>_pumpTask</c>, so a rapid
/// awaited when the last subscriber <em>of the live generation</em> leaves, so an unwatched
/// gateway runs no timer and builds no snapshots. Successive pumps are chained through
/// <c>_pumpTask</c>, so a rapid
/// unsubscribe/resubscribe restarts a fresh pump without ever running two enumerations at
/// once.
/// </para>
@@ -116,10 +117,25 @@ public sealed class DashboardSnapshotFeed : IDashboardSnapshotFeed
Task pump;
lock (_gate)
{
if (!_subscribers.Remove(subscription) || _subscribers.Count != 0)
if (!_subscribers.Remove(subscription))
{
// Either the pump already detached this subscription (it completed or
// faulted), or other viewers are still watching.
// The pump already detached this subscription (it completed or faulted).
return;
}
if (subscription.Generation != _generation)
{
// This viewer belonged to a generation that has already ended. The live
// pump — if there is one — serves other viewers and must not be cancelled
// on their behalf; the dying pump is stopping under its own steam.
return;
}
if (HasSubscribersLocked(_generation))
{
// Other viewers are still watching the live generation. Counting the whole
// list here would be wrong: subscribers of an ending generation linger in it
// until that pump's Reset runs, and they must not hold the idle gate open.
return;
}
@@ -149,6 +165,22 @@ public sealed class DashboardSnapshotFeed : IDashboardSnapshotFeed
}
}
/// <summary>Reports whether any subscriber is still being served by a generation.</summary>
/// <param name="generation">The generation to look for.</param>
/// <returns>True when at least one subscriber carries that generation.</returns>
private bool HasSubscribersLocked(long generation)
{
foreach (Subscription subscriber in _subscribers)
{
if (subscriber.Generation == generation)
{
return true;
}
}
return false;
}
/// <summary>
/// Starts a pump generation. Must be called while holding <c>_gate</c>; the caller adds
/// the subscribers that belong to the returned generation.