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:
@@ -32,6 +32,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
private DateTimeOffset _lastHeartbeatAt;
|
||||
private int? _processId;
|
||||
private int _eventQueueDepth;
|
||||
private int _eventsReaderClaimed;
|
||||
private Task? _readLoopTask;
|
||||
private Task? _writeLoopTask;
|
||||
private Task? _heartbeatLoopTask;
|
||||
@@ -70,7 +71,13 @@ public sealed class WorkerClient : IWorkerClient
|
||||
_events = Channel.CreateBounded<WorkerEvent>(
|
||||
new BoundedChannelOptions(_options.EventChannelCapacity)
|
||||
{
|
||||
SingleReader = false,
|
||||
// The worker event channel has exactly ONE consumer: the per-session
|
||||
// SessionEventDistributor pump. The alarm monitor and dashboard mirror both
|
||||
// attach to the distributor rather than draining this channel directly, so a
|
||||
// second concurrent reader would silently split events between the two
|
||||
// enumerators. SingleReader=true asserts that invariant; ReadEventsAsync adds a
|
||||
// claimed-once guard so a regression fails loudly instead of losing events.
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
AllowSynchronousContinuations = false,
|
||||
@@ -224,7 +231,24 @@ public sealed class WorkerClient : IWorkerClient
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The event channel is SingleReader: only one enumerator may ever drain it, otherwise
|
||||
// the two readers would each receive a random subset of events. Claim the reader at CALL
|
||||
// time (not lazily on first MoveNext) and fail loudly on a second consumer rather than
|
||||
// silently splitting the stream (see GWC-01). The distributor pump is the only intended
|
||||
// caller; the alarm monitor and dashboard mirror attach to the distributor instead.
|
||||
if (Interlocked.CompareExchange(ref _eventsReaderClaimed, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WorkerClient.ReadEventsAsync was already claimed by another consumer. The worker event "
|
||||
+ "channel is single-reader; attach to the SessionEventDistributor instead of draining it twice.");
|
||||
}
|
||||
|
||||
return ReadEventsCoreAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<WorkerEvent> ReadEventsCoreAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
|
||||
Reference in New Issue
Block a user