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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user