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,