fix(archreview): gateway core P0 remediation (GWC-01/02/03, TST-02, TST-12)

Interlocking changes across the gateway server (shared GatewaySession.cs /
SessionManager.cs), committed together:

- GWC-01 (Critical): alarm monitor now attaches as an internal
  (non-counted) distributor subscriber instead of a second raw drain of the
  single worker event channel; WorkerClient._events -> SingleReader with a
  claimed-once guard so a future dual-consumer regression throws loudly.
- GWC-02 (High): faulted sessions are swept in CloseExpiredLeasesAsync
  (IsFaultedReapable + FaultedReason); new FaultedGraceSeconds (default 0).
- GWC-03 (High): configurable MaxSparseArrayLength (default 1_000_000)
  enforced before allocation.
- TST-02 (High, security): StreamEvents attach now enforces the opening key
  id -> PermissionDenied on owner mismatch.
- TST-12 (Medium): CLAUDE.md retention-defaults sentence corrected.

Verified: NonWindows build clean; targeted tests 135/135 on macOS, plus
WorkerClientTests 18/18 on the Windows host.
This commit is contained in:
Joseph Doherty
2026-07-09 05:51:57 -04:00
parent 31eec41456
commit 20392cf246
30 changed files with 632 additions and 48 deletions
@@ -23,8 +23,10 @@ public sealed class GatewaySession
private bool _closeStarted;
private int _activeEventSubscriberCount;
private readonly TimeSpan _detachGrace;
private readonly TimeSpan _faultedGrace;
private readonly TimeSpan _workerReadyWaitTimeout;
private DateTimeOffset? _detachedAtUtc;
private DateTimeOffset? _faultedAtUtc;
// True once at least one external subscriber attached SUCCESSFULLY. Detach-grace's
// "last subscriber dropped" stamp (see DetachEventSubscriber) is gated on this so a
// FAILED first attach — which still runs the rollback DetachEventSubscriber from the
@@ -139,6 +141,14 @@ public sealed class GatewaySession
/// When <see langword="null"/> (legacy unit-construction paths that do not exercise Galaxy
/// metadata), addresses pass through unchanged.
/// </param>
/// <param name="faultedGrace">
/// Grace window kept after the session faults before the lease monitor reaps it. When the
/// window is positive the faulted session stays observable via <c>GetSessionStatus</c> for
/// that long before it is reclaimed; <see cref="TimeSpan.Zero"/> (the default) makes the
/// session reapable on the next sweep. The fault timestamp is stamped in
/// <see cref="MarkFaulted"/> using <paramref name="eventStreaming"/>'s clock so the timer
/// is unit-testable.
/// </param>
public GatewaySession(
string sessionId,
string backendName,
@@ -156,7 +166,8 @@ public sealed class GatewaySession
SessionEventStreaming? eventStreaming = null,
TimeSpan detachGrace = default,
TimeSpan workerReadyWaitTimeout = default,
ArrayAddressNormalizer? addressNormalizer = null)
ArrayAddressNormalizer? addressNormalizer = null,
TimeSpan faultedGrace = default)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -195,6 +206,7 @@ public sealed class GatewaySession
_leaseExpiresAt = openedAt + leaseDuration;
_eventStreaming = eventStreaming ?? SessionEventStreaming.Default;
_detachGrace = detachGrace > TimeSpan.Zero ? detachGrace : TimeSpan.Zero;
_faultedGrace = faultedGrace > TimeSpan.Zero ? faultedGrace : TimeSpan.Zero;
_workerReadyWaitTimeout = workerReadyWaitTimeout > TimeSpan.Zero ? workerReadyWaitTimeout : TimeSpan.Zero;
_addressNormalizer = addressNormalizer;
}
@@ -522,6 +534,33 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Registers a gateway-owned <em>internal</em> (non-counted) distributor subscriber and
/// returns its lease. The lease's <see cref="IEventSubscriberLease.Reader"/> yields the
/// same mapped <see cref="MxEvent"/>s the single distributor pump fans to every
/// subscriber; disposing the lease unregisters it.
/// </summary>
/// <remarks>
/// Used by the central alarm monitor so it consumes events through the one distributor
/// pump instead of opening a second raw drain of the single worker event channel (which
/// would split events between the two readers). Mirrors the dashboard-mirror lease:
/// <c>isInternal: true</c> keeps this subscriber out of the
/// <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.
/// </remarks>
/// <returns>The internal subscriber's lease; dispose it to unregister.</returns>
public IEventSubscriberLease AttachInternalEventSubscriber()
{
// 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.
SessionEventDistributor distributor = EnsureDistributorCreated(out bool startNow);
IEventSubscriberLease lease = distributor.Register(isInternal: true);
StartPumpIfRequested(distributor, startNow);
return lease;
}
private static void StartPumpIfRequested(SessionEventDistributor distributor, bool startNow)
{
if (!startNow)
@@ -724,6 +763,11 @@ public sealed class GatewaySession
_finalFault = reason;
_state = SessionState.Faulted;
// Stamp the fault time once, on the first fault, so the sweeper can apply
// FaultedGraceSeconds. A subsequent MarkFaulted (already-faulted session) keeps the
// original timestamp so the grace window is measured from the first fault.
_faultedAtUtc ??= _eventStreaming.TimeProvider.GetUtcNow();
}
}
@@ -790,6 +834,24 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Determines whether a faulted session is now eligible for reaping by the lease monitor.
/// A faulted session is permanently unusable (every command fails the readiness check),
/// so the sweeper closes it exactly as it closes an expired lease — but no sooner than the
/// configured <c>FaultedGraceSeconds</c> after the fault, so a monitoring client can still
/// observe the fault before the slot is reclaimed. Always returns <see langword="false"/>
/// for a non-faulted session.
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
/// <returns><see langword="true"/> if the session is faulted and past its fault-grace window; otherwise <see langword="false"/>.</returns>
public bool IsFaultedReapable(DateTimeOffset now)
{
lock (_syncRoot)
{
return IsFaultedReapableCore(now);
}
}
/// <summary>
/// Attaches an event subscriber and returns a lease whose
/// <see cref="IEventSubscriberLease.Reader"/> reads the fanned public
@@ -1053,12 +1115,13 @@ public sealed class GatewaySession
_addressNormalizer?.Normalize(address) ?? address;
// MXAccess writes replace the whole array; expand a sparse value in place so the worker only
// ever receives a whole-array MxValue. No-op for null or non-sparse values.
private static void ExpandValue(MxValue? value)
// ever receives a whole-array MxValue. No-op for null or non-sparse values. The configured
// MxGateway:Events:MaxSparseArrayLength cap is enforced before the full array is allocated.
private void ExpandValue(MxValue? value)
{
if (value is not null)
{
SparseArrayExpander.Expand(value);
SparseArrayExpander.Expand(value, _eventStreaming.EventOptions.MaxSparseArrayLength);
}
}
@@ -1571,7 +1634,7 @@ public sealed class GatewaySession
// Re-verify eligibility atomically. If a subscriber reattached between the sweep's
// eligibility check and this point, neither condition holds and we decline.
bool eligible = IsLeaseExpiredCore(now) || IsDetachGraceExpiredCore(now);
bool eligible = IsLeaseExpiredCore(now) || IsFaultedReapableCore(now) || IsDetachGraceExpiredCore(now);
if (!eligible)
{
alreadyClosing = false;
@@ -1597,6 +1660,12 @@ public sealed class GatewaySession
&& _detachedAtUtc is not null
&& now - _detachedAtUtc.Value >= _detachGrace;
private bool IsFaultedReapableCore(DateTimeOffset now)
=> _state is SessionState.Faulted
&& (_faultedGrace <= TimeSpan.Zero
|| _faultedAtUtc is null
|| now - _faultedAtUtc.Value >= _faultedGrace);
// Final terminal transition; under _syncRoot to keep _state writes single-lock.
// Closed is unconditionally terminal — TransitionTo refuses to overwrite it —
// so we don't need to re-check the precondition here.