fix(GWC-34): fail an attach that races session disposal instead of hanging it

EnsureDistributorCreated read a null _eventDistributor as "not created yet",
which is true before MarkReady and false after DisposeAsync nulled it. An
attach that passed its readiness gate while the session was still Ready and
then lost the CPU across the whole close + dispose therefore built a
REPLACEMENT distributor: startNow came back false because the pump-start flag
is a one-way latch, so the subscriber registered on a distributor whose pump
never starts and whose channel is never completed, and EventStreamService
blocked in WaitToReadAsync until the client's own deadline — no error, no log,
no metric, the backlog gauge reporting 0 forever. This is the disposal-side
twin of the premature-attach poison GWC-27 closed.

DisposeAsync now sets _eventDistributorDisposed under _syncRoot in the same
section that nulls the distributor, and EnsureDistributorCreated throws
SessionManagerException(SessionNotReady) when it is set. The attach paths'
existing catch/rollback releases the subscriber slot, so a second attempt
still reports SessionNotReady rather than EventSubscriberAlreadyActive.

StartDashboardMirror is the one caller that is not an attach: it abandons the
mirror (releasing its loop token source) instead of propagating, so MarkReady
stays total. Losing that race previously left a started orphan distributor
behind, so this path strictly improves too.

Test: AttachAfterDisposeThrowsSessionNotReadyInsteadOfRegisteringOnANeverPumped-
Distributor covers all three attach entry points plus the rollback, and fails
on the pre-fix code. docs/Sessions.md records the disposal end of the
one-distributor-per-session rule.
This commit is contained in:
Joseph Doherty
2026-08-20 09:25:21 -04:00
parent 058d8d529f
commit 282a0e70ad
3 changed files with 98 additions and 1 deletions
@@ -719,6 +719,47 @@ public sealed class GatewaySessionTests
Assert.Equal([1UL, 2UL], received.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
}
/// <summary>
/// GWC-34, the disposal-side twin of the GWC-27 premature-attach poison: an attach that
/// passed its readiness gate and then lost the CPU until the session was torn down used to
/// find <c>_eventDistributor</c> null, read that as "not created yet" and build a
/// REPLACEMENT distributor. Its pump could never start (the started flag is a one-way latch)
/// and its channel was never completed, so the subscriber registered on it and blocked in
/// <c>WaitToReadAsync</c> until its own deadline — no error, no log, no metric. Disposal now
/// latches the null-out, so every attach entry point fails immediately and loudly instead.
/// Attaching twice pins the rollback: the second call must still report
/// <c>SessionNotReady</c>, not <c>EventSubscriberAlreadyActive</c>, which it only can if the
/// first failure released the subscriber slot it took.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachAfterDisposeThrowsSessionNotReadyInsteadOfRegisteringOnANeverPumpedDistributor()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
// Disposal leaves the session state Ready (only CloseAsync transitions it), so the attach
// paths' readiness gates pass and the calls below genuinely reach EnsureDistributorCreated —
// the exact position the racing attach in the finding occupies.
await session.DisposeAsync();
SessionManagerException attachException = Assert.Throws<SessionManagerException>(
() => session.AttachEventSubscriber(maxSubscribers: 1));
Assert.Equal(SessionManagerErrorCode.SessionNotReady, attachException.ErrorCode);
SessionManagerException secondAttachException = Assert.Throws<SessionManagerException>(
() => session.AttachEventSubscriber(maxSubscribers: 1));
Assert.Equal(SessionManagerErrorCode.SessionNotReady, secondAttachException.ErrorCode);
SessionManagerException replayException = Assert.Throws<SessionManagerException>(
() => session.AttachEventSubscriberWithReplay(maxSubscribers: 1, afterSequence: 0));
Assert.Equal(SessionManagerErrorCode.SessionNotReady, replayException.ErrorCode);
SessionManagerException internalException = Assert.Throws<SessionManagerException>(
() => session.AttachInternalEventSubscriber());
Assert.Equal(SessionManagerErrorCode.SessionNotReady, internalException.ErrorCode);
}
private static GatewaySession CreateSession()
{
return new GatewaySession(