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
@@ -1095,6 +1095,59 @@ public sealed class SessionManagerTests
Assert.Equal(0, activeClient.ShutdownCount);
}
/// <summary>
/// Every sweep-driven close — lease expiry, faulted reap and detach-grace expiry — must
/// account <c>SessionClosed</c> exactly once. The sweep begins the close itself
/// (<c>TryBeginCloseIfExpired</c>) before delegating to the shared close path, and that
/// reservation must not make the delegated close look like a second, already-running close
/// whose accounting is skipped: the open-session gauge would then never drop for swept
/// sessions and the closed counter would under-count them.
/// </summary>
/// <param name="sweepKind">Which sweep condition selects the session.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(SessionManager.LeaseExpiredReason)]
[InlineData(SessionManager.FaultedReason)]
[InlineData(SessionManager.DetachGraceExpiredReason)]
public async Task CloseExpiredLeasesAsync_SweptClose_AccountsSessionClosedExactlyOnce(string sweepKind)
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
metrics: metrics,
options: CreateOptions(detachGraceSeconds: 30),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
switch (sweepKind)
{
case SessionManager.LeaseExpiredReason:
SetLeaseExpiry(session, clock.GetUtcNow().AddSeconds(-1));
break;
case SessionManager.FaultedReason:
session.MarkFaulted("test-fault");
break;
case SessionManager.DetachGraceExpiredReason:
session.AttachEventSubscriber(maxSubscribers: 1).Dispose();
clock.Advance(TimeSpan.FromSeconds(31));
break;
default:
throw new ArgumentOutOfRangeException(nameof(sweepKind), sweepKind, "Unknown sweep kind.");
}
int closedCount = await manager.CloseExpiredLeasesAsync(clock.GetUtcNow(), CancellationToken.None);
Assert.Equal(1, closedCount);
Assert.Equal(SessionState.Closed, session.State);
Assert.False(manager.TryGetSession(session.SessionId, out _));
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(1, snapshot.SessionsClosed);
Assert.Equal(0, snapshot.OpenSessions);
}
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1436,6 +1489,17 @@ public sealed class SessionManagerTests
};
}
/// <summary>
/// Moves a session's lease expiry by back-dating its last client activity, which is the only
/// production path that moves the lease (<c>_leaseExpiresAt = activityAt + LeaseDuration</c>).
/// </summary>
/// <param name="session">Session whose lease to move.</param>
/// <param name="leaseExpiresAt">Wanted lease expiry.</param>
private static void SetLeaseExpiry(GatewaySession session, DateTimeOffset leaseExpiresAt)
{
session.TouchClientActivity(leaseExpiresAt - session.LeaseDuration);
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(