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,
|
||||
|
||||
Reference in New Issue
Block a user