fix(GWC-27): gate AttachInternalEventSubscriber on session readiness

AttachInternalEventSubscriber ran EnsureDistributorCreated / Register /
StartPumpIfRequested with no state check, unlike AttachEventSubscriber. A
premature attach would start the pump against a not-yet-Ready worker; the pump
source throws SessionNotReady, PumpAsync completes every subscriber with that
error and latches the distributor, and _eventDistributorStarted is never reset —
so the session would reach Ready with permanently dead event streaming.

Mirror AttachEventSubscriber's gate: check _state/_workerClient.State under
_syncRoot and throw SessionManagerException(SessionNotReady) before the
distributor is created, keeping the distributor calls outside the lock.

Refs: archreview/2026-07-12/remediation/10-gateway-core.md GWC-27
This commit is contained in:
Joseph Doherty
2026-08-07 05:28:35 -04:00
parent ead921cace
commit 1a63fdd7db
2 changed files with 110 additions and 1 deletions
@@ -548,10 +548,37 @@ public sealed class GatewaySession
/// <c>MaxEventSubscribersPerSession</c> accounting and out of the single-subscriber
/// overflow-fault path, so a slow alarm reconcile can never fault the session — it only
/// disconnects this internal subscriber.
/// <para>
/// Gated on readiness exactly like <see cref="AttachEventSubscriber"/>: attaching
/// before the session and its worker are <c>Ready</c> throws
/// <see cref="SessionManagerException"/> with
/// <see cref="SessionManagerErrorCode.SessionNotReady"/>.
/// </para>
/// </remarks>
/// <returns>The internal subscriber's lease; dispose it to unregister.</returns>
/// <exception cref="SessionManagerException">
/// The session or its worker client is not <c>Ready</c>.
/// </exception>
public IEventSubscriberLease AttachInternalEventSubscriber()
{
// Readiness gate, mirroring AttachEventSubscriber (GWC-27). It must run BEFORE
// EnsureDistributorCreated: a premature attach would construct the distributor and start
// its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady,
// PumpAsync would complete every subscriber with that error and latch _completed, and
// _eventDistributorStarted is never reset — so the session would reach Ready with
// permanently dead event streaming, silently, for the rest of its life. Failing loudly
// here keeps that state unreachable. The check is under _syncRoot and the distributor
// calls stay outside it, matching AttachEventSubscriber's lock discipline.
lock (_syncRoot)
{
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotReady,
$"Session {SessionId} is not ready for event streaming. Current state is {_state}.");
}
}
// Same sequence StartDashboardMirror uses: create the distributor (claiming the pump
// start if we are first), register the internal subscriber BEFORE the pump starts so a
// subscriber is always present at pump start, then start the pump if requested.