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:
@@ -301,6 +301,12 @@ public sealed class SessionManager : ISessionManager
|
||||
// reconnected within DetachGraceSeconds). The detach-grace close is the same
|
||||
// teardown as a lease-expiry close; only the reason differs so operators can tell
|
||||
// a short reconnect-window expiry from a long idle-lease expiry in logs/metrics.
|
||||
// Lease-expiry takes PRECEDENCE over detach-grace when both conditions fire
|
||||
// simultaneously (reason will be lease-expired, not detach-grace-expired).
|
||||
//
|
||||
// TOCTOU note: eligibility is re-verified atomically inside TryBeginCloseIfExpired
|
||||
// under _syncRoot, so a client that reattaches a subscriber between the check above
|
||||
// and the close call wins the race and the session is left open and usable.
|
||||
string? reason = session.IsLeaseExpired(now)
|
||||
? LeaseExpiredReason
|
||||
: session.IsDetachGraceExpired(now)
|
||||
@@ -311,6 +317,15 @@ public sealed class SessionManager : ISessionManager
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-verify eligibility atomically and begin the Closing transition before
|
||||
// delegating to CloseSessionCoreAsync. If a subscriber reattached between the
|
||||
// IsLeaseExpired/IsDetachGraceExpired check above and here, TryBeginCloseIfExpired
|
||||
// returns false and we skip this session (it is no longer expired).
|
||||
if (!session.TryBeginCloseIfExpired(now, out bool alreadyClosing) && !alreadyClosing)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await CloseSessionCoreAsync(session, reason, cancellationToken).ConfigureAwait(false);
|
||||
closedCount++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user