fix(sessions): account SessionClosed for sweep-driven closes

TryBeginCloseIfExpired set _closeStarted when it reserved a session, so the
sweep's own CloseAsync reported AlreadyClosed: true and CloseSessionCoreAsync
skipped SessionClosed: lease-expired, faulted-reaped and detach-grace-expired
closes never left the sessions.open gauge and under-counted sessions.closed.
The reservation now writes Closing only; the first CloseAsync to take the
close lock does the accounting exactly once.
This commit is contained in:
Joseph Doherty
2026-10-22 06:19:20 -04:00
parent 87f23d9712
commit 09666cc719
3 changed files with 75 additions and 1 deletions
@@ -1822,6 +1822,15 @@ public sealed class GatewaySession
/// committing to <c>Closing</c>, so a reattach that wins the race leaves the session
/// in <c>Ready</c> and usable.
/// </para>
/// <para>
/// This is a <em>reservation</em>, not the close itself: it flips the state so no
/// reattach can revive the session and no other sweep pass re-selects it, but it
/// deliberately leaves <c>_closeStarted</c> untouched. The sweep's own
/// <see cref="CloseAsync"/> that follows is the first close to run, and must report
/// <c>AlreadyClosed: false</c> so the manager accounts <c>SessionClosed</c> for it;
/// setting <c>_closeStarted</c> here made that close look like a second, concurrent one
/// and every sweep-driven close went unaccounted.
/// </para>
/// </remarks>
internal bool TryBeginCloseIfExpired(DateTimeOffset now, out bool alreadyClosing)
{
@@ -1843,7 +1852,6 @@ public sealed class GatewaySession
}
alreadyClosing = _closeStarted;
_closeStarted = true;
_state = SessionState.Closing;
return true;
}