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