fix(sessions): expose DetachGraceSeconds in effective-config; single clock; close reconnect-vs-sweep race

- EffectiveSessionConfiguration: add DetachGraceSeconds field; GatewayConfigurationProvider
  forwards value.Sessions.DetachGraceSeconds (blocker fix).
- GatewaySession.InvokeAsync and ReadEventsAsync: switch TouchClientActivity calls from
  DateTimeOffset.UtcNow to _eventStreaming.TimeProvider.GetUtcNow() so Task 12 fake-clock
  control works end-to-end (split-clock fix).
- TOCTOU fix: add TryBeginCloseIfExpired(now, out alreadyClosing) to GatewaySession that
  re-checks IsLeaseExpiredCore/IsDetachGraceExpiredCore AND _activeEventSubscriberCount==0
  under _syncRoot before transitioning to Closing; CloseExpiredLeasesAsync calls it before
  CloseSessionCoreAsync so a reattach that wins the race leaves the session Ready/usable.
- Minors: lease-expiry-takes-precedence comment in CloseExpiredLeasesAsync; TOCTOU comment
  block; sweep-cycle latency note added to SessionOptions.DetachGraceSeconds XML doc and to
  GatewayConfiguration.md DetachGraceSeconds row.
- New tests: TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose (GatewaySession),
  CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses (SessionManager),
  plus IsLeaseExpiredCore/IsDetachGraceExpiredCore private helpers used by the guard.
This commit is contained in:
Joseph Doherty
2026-06-16 07:11:59 -04:00
parent db95f8644f
commit 042f5e3d82
8 changed files with 173 additions and 3 deletions
@@ -577,6 +577,43 @@ public sealed class GatewaySessionTests
Assert.True(session.IsDetachGraceExpired(clock.GetUtcNow()));
}
/// <summary>
/// Task 11. Validates the TOCTOU fix: TryBeginCloseIfExpired atomically re-checks that no
/// subscriber has reattached before flipping to Closing. When the grace window has elapsed but
/// a subscriber is attached by the time TryBeginCloseIfExpired runs, it returns false and the
/// session remains Ready.
/// </summary>
[Fact]
public async Task TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
await using GatewaySession session = CreateReadySessionWithDetachGrace(
workerClient,
clock,
detachGrace: TimeSpan.FromSeconds(30));
// Attach then drop to enter detach-grace, then advance past the window.
IDisposable firstSubscriber = session.AttachEventSubscriber(maxSubscribers: 1);
firstSubscriber.Dispose();
clock.Advance(TimeSpan.FromSeconds(31));
DateTimeOffset expiredNow = clock.GetUtcNow();
Assert.True(session.IsDetachGraceExpired(expiredNow)); // sanity: would be closed by sweep
// Simulate a client reconnecting before the sweeper calls TryBeginCloseIfExpired.
// The reattach clears _detachedAtUtc and increments _activeEventSubscriberCount so
// neither expiry condition holds any longer.
using IDisposable reconnected = session.AttachEventSubscriber(maxSubscribers: 1);
Assert.Null(session.DetachedAtUtc);
// TryBeginCloseIfExpired must see the reattach and decline — the session stays Ready.
bool began = session.TryBeginCloseIfExpired(expiredNow, out bool alreadyClosing);
Assert.False(began);
Assert.False(alreadyClosing);
Assert.Equal(SessionState.Ready, session.State);
}
private static GatewaySession CreateReadySessionWithDetachGrace(
IWorkerClient workerClient,
TimeProvider timeProvider,
@@ -789,6 +789,47 @@ public sealed class SessionManagerTests
Assert.Equal(1, workerClient.ShutdownCount);
}
/// <summary>
/// Task 11. TOCTOU race: a session whose detach-grace window has expired but that
/// reattaches an external subscriber before the sweeper calls CloseSessionCoreAsync is
/// NOT closed — it remains Ready and usable. This validates that TryBeginCloseIfExpired
/// re-checks eligibility atomically so a reconnect that wins the race cancels the close.
/// </summary>
[Fact]
public async Task CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses()
{
FakeWorkerClient workerClient = new();
FakeTimeProvider clock = new(DateTimeOffset.UtcNow);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
options: CreateOptions(defaultLeaseSeconds: 1800, detachGraceSeconds: 30),
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
// Attach and drop an external subscriber so the session enters detach-grace.
IDisposable firstSubscriber = session.AttachEventSubscriber(maxSubscribers: 1);
firstSubscriber.Dispose();
Assert.NotNull(session.DetachedAtUtc);
// Advance past the grace window so IsDetachGraceExpired returns true.
clock.Advance(TimeSpan.FromSeconds(31));
DateTimeOffset sweepTime = clock.GetUtcNow();
// Simulate a client reattaching before the sweep actually closes the session.
// The reattach clears _detachedAtUtc and increments _activeEventSubscriberCount,
// so TryBeginCloseIfExpired will see neither condition as met and decline.
using IDisposable reconnectedSubscriber = session.AttachEventSubscriber(maxSubscribers: 1);
Assert.Null(session.DetachedAtUtc);
// The sweep runs with the timestamp that was past the grace window, but since the
// subscriber has reattached, the session must NOT be closed.
int closedCount = await manager.CloseExpiredLeasesAsync(sweepTime, CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, workerClient.ShutdownCount);
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()