Files
mxaccessgw/archreview/2026-07-12/10-gateway-core.md
T
Joseph Doherty 1a5a12a416
ci / java (push) Successful in 1m54s
ci / portable (push) Successful in 7m6s
docs(archreview): 2026-07-12 follow-up review + remediation designs
Six-domain re-review at the P2 merge (4f5371f): all prior Done claims
verified (none false, 10 partial), 47 new findings (1 High, 14 Medium),
with per-finding design/implementation plans and a new tracking register.
2026-07-13 00:18:38 -04:00

115 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gateway Server Core — Post-Remediation Re-Review
- **Date:** 2026-07-12
- **Commit:** `4f5371f` (`main` — merge of P0/P1/P2 remediation tiers + CI)
- **Baseline:** `59856b8` (pre-remediation)
- **Method:** static review on macOS; every cited file read in full at HEAD. No build, no test, no source change.
- **Scope:** `src/ZB.MOM.WW.MxGateway.Server``Sessions/`, `Workers/`, `Grpc/` (command invoke + `EventStreamService`), `Alarms/`. Excludes Security/authn/authz, dashboard, metrics endpoints (other agent) except where session/stream behavior touches them.
- **Prior report:** `archreview/10-gateway-core.md` (2026-07-08); remediation design `archreview/remediation/10-gateway-core.md`; status source `archreview/remediation/00-tracking.md`.
## Prior-finding verification
Every `Done` claim was verified against code read at HEAD; every open finding was re-checked for incidental fixes. **All 10 Done claims hold and are sound. All 12 open findings are still open (none incidentally fixed); GWC-13 was partially improved.** No Done claim failed verification.
| ID | Claimed | Verified? | Evidence (HEAD) | Notes |
|----|---------|-----------|-----------------|-------|
| GWC-01 | Done | ✅ holds | `Workers/WorkerClient.cs:79-92` (`_events` now `SingleReader = true` with invariant comment), `:267-282` (`Interlocked.CompareExchange` claimed-once guard in `ReadEventsAsync`, throws on second consumer); `Sessions/GatewaySession.cs:553-562` (`AttachInternalEventSubscriber`, `isInternal: true`); `Sessions/SessionManager.cs:195-208` (`ReadAlarmEventsAsync` lease-based); `Alarms/GatewayAlarmMonitor.cs:232-234` (monitor consumes mapped `MxEvent`s through the distributor, no raw drain) | Sound. The dual-drain race is structurally closed and future regressions fail loudly. Residual one-time gap at monitor startup → new GWC-26. |
| GWC-02 | Done | ✅ holds | `Sessions/GatewaySession.cs:755-772` (`MarkFaulted` stamps `_faultedAtUtc` once), `:847-853` + `:1663-1667` (`IsFaultedReapable(Core)` with `FaultedGraceSeconds`), `:1637` (faulted arm inside the atomic `TryBeginCloseIfExpired` re-check); `Sessions/SessionManager.cs:22` (`FaultedReason`), `:287-293` (sweep ladder lease → faulted → detach-grace); `Configuration/SessionOptions.cs:58` (`FaultedGraceSeconds`, default 0 = next sweep); `Configuration/GatewayOptionsValidator.cs:265-266` | Sound; uses the existing TOCTOU-safe close gate as designed. Clock consistency verified: fault stamp and sweeper share the same `TimeProvider` (`SessionManager.cs:450-454`, `:474-477`). |
| GWC-03 | Done | ✅ holds | `Sessions/SparseArrayExpander.cs:72-76` (configured cap checked **before** allocation, message names `MxGateway:Events:MaxSparseArrayLength`), `:78-82` (`Array.MaxLength` backstop kept); `Configuration/EventOptions.cs:39` (default 1,000,000); `Sessions/GatewaySession.cs:1120-1126` (wired from `EventOptions` at the choke point); `Configuration/GatewayOptionsValidator.cs:303-304` (range-validated at startup) | Sound. Note the rejection still throws `RpcException` — the new check inherits open GWC-17's layering leak. |
| GWC-04 | Done | ✅ holds | `Workers/WorkerClient.cs:28-33`/`:93-100` (unbounded `_eventStaging`, single-reader/single-writer), `:518-554` (`DispatchEnvelope` fully synchronous; replies/heartbeats/faults/acks never queue behind events), `:565-573` (`StageWorkerEvent` non-blocking `TryWrite`), `:580-593` (`EventWriteLoopAsync` owns the timed write + sustained-overflow `ProtocolViolation` fault, `:610-647`); loop registered in `WaitForBackgroundTasksAsync` (`:1069`) and completed on close/fault/dispose (`:363-365`, `:803-805`, `:837-839`) | Fix works as designed for the reported defect (reply-behind-event stall). However the *unbounded* staging channel introduces a backpressure regression → new GWC-24. |
| GWC-05 | Not started | ✅ still open | `Sessions/SessionWorkerClientFactory.cs:158-166` — plain `NamedPipeServerStream(..., PipeOptions.Asynchronous)`, no ACL, no `CurrentUserOnly` | Local pipe-squatting DoS unchanged; `gateway.md` pipe-security contract still unmet. |
| GWC-06 | Done | ✅ holds | `Grpc/MxAccessGatewayService.cs:155-163``Stopwatch.GetTimestamp()` / `Stopwatch.GetElapsedTime(ts)`, no per-event allocation | |
| GWC-07 | Done | ✅ holds | `Grpc/MxAccessGrpcMapper.cs:64-82``MapEvent` transfers ownership of `workerEvent.Event` (no `.Clone()`), with the ownership-transfer invariant documented and tied to GWC-01's single-reader guard | Downstream sharing audited: subscribers/replay ring are read-only; the dashboard broadcaster redacts on a deep clone (per SEC-25 change log), so the shared instance is never mutated. Safe. |
| GWC-08 | Done | ✅ holds | `Workers/WorkerFrameWriter.cs:57-76` — single `ArrayPool` rent, LE prefix + `WriteTo(Span)`, one `WriteAsync`, `finally` return; `Workers/WorkerFrameReader.cs:51-79` — pooled payload buffer, length-bounded read/parse, returned in `finally`; validation order preserved (`:37-49` before rent) | Sound. Residual nit: the reader still allocates a fresh 4-byte prefix array per frame (`WorkerFrameReader.cs:33`) → GWC-30 (Info). |
| GWC-09 | Not started | ✅ still open | `Workers/WorkerProcessStartedProbe.cs:5-19` (no-op `HasExited` check throwing `WorkerProcessLaunchException`); `Workers/WorkerProcessLauncher.cs:290-298` (`ShouldRetryStartupProbe` still excludes `WorkerProcessLaunchException`); `Configuration/WorkerOptions.cs:19-22` (both dead options remain) | Retry pipeline still can never retry. |
| GWC-10 | Not started | ✅ still open | `Workers/WorkerEnvelopeValidator.cs:15-39` — validates version/session/body only; no sequence tracking anywhere in `WorkerClient` | Related new gateway-side emission defect → GWC-28. |
| GWC-11 | Not started | ✅ still open | `Sessions/GatewaySession.cs:18` (`_workerClient` not volatile); lock-free reads at `:280` (`WorkerProcessId`), `:285` (new public `WorkerClient` property — one **more** unguarded read than at baseline), `:1533` (`CloseAsync`), `:1684`, `:1719`, `:1831` (`DisposeAsync`) | |
| GWC-12 | Not started | ✅ still open | `Workers/WorkerClient.cs:353-360` — plain `if (_disposed) return; _disposed = true;`, no interlock | |
| GWC-13 | Not started | ✅ still open (partially improved) | `Sessions/GatewaySession.cs:1910-1978` — still a 25 ms poll loop, but the delay now routes through the injected `TimeProvider` (`:1940-1944`), improving testability | Design doc recommended defer-until-used; acceptable. |
| GWC-14 | Done | ✅ holds | `Sessions/SessionEventDistributor.cs:100-118` (preallocated circular `ReplayEntry[]`, head+count, invariants documented), `:776-810` (allocation-free append with capacity overwrite), `:814` (`ReplayEntryAt` modular indexing), `:818-831` (age eviction advances head) | Wraparound math verified (`_replayBuffer.Length == _replayBufferCapacity`; capacity-0 guarded before any modulo). Query paths preserve ascending order. Correct. |
| GWC-15 | Done | ✅ holds | `Grpc/EventStreamService.cs:123-124` (per-stream backlog source registration; gauge reads `Reader.Count` only at scrape), `:197` (disposed before the lease in `finally`); `Metrics/GatewayMetrics.cs:314-320`, `:560-573` (idempotent registration handle) | |
| GWC-16 | Not started | ✅ still open | `Grpc/MxAccessGatewayService.cs:104` (`ResolveSession`) + `:122-123` (`sessionManager.InvokeAsync(request.SessionId, …)`) → `Sessions/SessionManager.cs:163` (`GetRequiredSession`) — still two registry lookups per command | |
| GWC-17 | Not started | ✅ still open | `Sessions/SparseArrayExpander.cs:296-297` (`RpcException(InvalidArgument)` below the gRPC layer); invoked from `Sessions/GatewaySession.cs:1043-1045` (comment explicitly documents the leak) | GWC-03's new cap rejection flows through the same leak. |
| GWC-18 | Not started | ✅ still open | `Sessions/GatewaySession.cs:13``public sealed class GatewaySession` declares no `IAsyncDisposable`; `DisposeAsync` at `:1741` | |
| GWC-19 | Not started | ✅ still open | `Sessions/GatewaySession.cs:1684-1688``KillWorker(string)` present; grep across `src/` + `clients/` finds zero callers (only `KillWorkerWithCloseGateAsync` is used) | |
| GWC-20 | Not started | ✅ still open | `Configuration/WorkerOptions.cs:30-31` (`HeartbeatIntervalSeconds` doc/name unchanged) bound to the check interval at `Sessions/SessionWorkerClientFactory.cs:86`; `Workers/WorkerClient.cs:458` heartbeat loop still uses raw `Task.Delay` without `TimeProvider` | |
| GWC-21 | Not started | ✅ still open | `Sessions/SessionWorkerClientFactory.cs:83-89` populates 4 of 6 `WorkerClientOptions`; `EventChannelFullModeTimeout` / `HeartbeatStuckCeiling` fixed at defaults (`Workers/WorkerClientOptions.cs:13`, `:24`) | Post-GWC-04 the full-mode timeout is now the *sole* structural backpressure bound (see GWC-24), which raises the value of exposing it. |
| GWC-22 | Not started | ✅ still open | `Grpc/EventStreamService.cs:200``metrics.StreamDisconnected("Detached")` hardcoded in the `finally`; fault/overflow paths (`:164-174`) do not differentiate | |
| GWC-23 | N/A | ✅ unchanged | Validator comment still documents the knowingly-dead knob | No action, as agreed. |
Also verified in passing (other-domain fixes landing in gateway-core files, all sound): IPC-03 gateway half — pre-checked command envelope size failing only the offending correlation (`Workers/WorkerClient.cs:209-221`, `CommandTooLarge``ResourceExhausted` at `Grpc/MxAccessGatewayService.cs:955`); IPC-02 — `GatewayHello.MaxFrameBytes` conveyed (`WorkerClient.cs:986-988`) with startup cross-validation `Worker.MaxMessageBytes >= gRPC cap + 64 KiB reserve` (`Configuration/GatewayOptionsValidator.cs:481-495`); IPC-04 — `DrainEvents max_events` bounded at 10,000 (`Grpc/MxAccessGrpcRequestValidator.cs:13`, `:81-84`); TST-02 — owner-scoped `StreamEvents` attach (`Grpc/EventStreamService.cs:57-62`).
## New findings
IDs continue from the prior register.
### GWC-24 — The GWC-04 staging channel is unbounded: sustained slow consumption now grows memory instead of faulting, and the backlog is invisible to metrics
- **Severity:** Medium
- **Category:** Stability / backpressure regression
- **Location:** `Workers/WorkerClient.cs:93-100` (unbounded `_eventStaging`), `:565-573` (`StageWorkerEvent` `TryWrite` always succeeds), `:610-647` (fault fires only when a *single* timed write exceeds `EventChannelFullModeTimeout`), `:616`/`:627` (`_eventQueueDepth` gauge counts `_events` only)
- **Description:** Before GWC-04, a full `_events` channel blocked the read loop, which propagated backpressure through the pipe to the worker's own bounded queue — end-to-end, structurally bounded. Now the read loop stages events into an unbounded channel and only `EventWriteLoopAsync` faces the bounded `_events`. The sustained-overflow fault fires only when one `WriteAsync` waits longer than 5 s; if the consumer (the distributor pump) drains slower than the worker produces but each individual write completes within the window, `_eventStaging` grows without bound and without any fault — and the growth is invisible, because `SetWorkerEventQueueDepth` reflects only `_events`, never the staging backlog. The code comment (`:28-33`) asserts staging "only fills during the bounded EventChannelFullModeTimeout window", which is true only for a full stall, not for sustained slow drain. Mitigating factor: the pump is non-blocking (per-subscriber `TryWrite` fan-out, `SessionEventDistributor.cs:582-594`), so a persistently slow-but-live consumer requires the pump itself to be starved — unlikely but no longer structurally impossible. The repo's fail-fast backpressure invariant now rests on pump liveness rather than on channel bounds.
- **Recommendation:** Bound the staging channel (e.g. `2 × EventChannelCapacity`) and treat a `TryWrite` miss there as an immediate `ProtocolViolation` fault (a full staging channel means the event writer is already saturated); alternatively track total staged-but-undelivered depth and fault past a ceiling. Include the staging depth in the worker-event queue-depth gauge either way.
### GWC-25 — `ReplayGap` sentinel carries `oldest_available_sequence = 0` when the ring is empty, violating the proto resume contract and dead-streaming a compliant client
- **Severity:** Medium
- **Category:** Correctness / contract
- **Location:** `Sessions/SessionEventDistributor.cs:463-467` (`RegisterWithReplay`, `_replayCount == 0` branch: `oldestAvailableSequence = 0`); emitted verbatim by `Grpc/EventStreamService.cs:133-139` + `:210-222`; contract at `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:755-763`
- **Description:** The proto contract states "`oldest_available_sequence` itself IS still retained: a client that wishes to resume without incurring another gap MUST set `after_worker_sequence = oldest_available_sequence - 1`". When a resume finds a gap with an *empty* ring (all entries age-evicted — default `ReplayRetentionSeconds = 300` and `EvictAged` runs inside `RegisterWithReplay` at `:458` — or `ReplayBufferCapacity = 0` with the cursor behind `_highestSequenceSeen`), the sentinel carries `oldest_available_sequence = 0`. A contract-compliant client computes `0 1` on a uint64 → `2^641`, and its next resume passes `after_worker_sequence = ulong.MaxValue`: `RegisterWithReplay` replays nothing, reports no gap, and the live filter `mxEvent.WorkerSequence <= afterWorkerSequence` (`Grpc/EventStreamService.cs:179`) then drops **every** live event — a silently dead stream with no error. All five clients shipped the `oldest 1` resume formula in CLI-15 (per the tracking change log), so the failure mode is reachable end-to-end.
- **Recommendation:** In the empty-ring-with-gap branch, emit `oldest_available_sequence = _highestSequenceSeen + 1` (the next sequence that can possibly be delivered), so `oldest 1 = highestSeen` resumes cleanly; keep 0 only for the never-populated case where no gap is reported anyway. Alternatively define the 0 special case in the proto and fix all five clients — the server-side fix is one line and needs no client changes, so prefer it. Add a distributor test for gap-with-empty-ring and an e2e resume after full age eviction.
### GWC-26 — Alarm monitor attaches its event subscriber only *after* SubscribeAlarms + first reconcile; transitions in that window bypass the feed, and a missed Acknowledge is never broadcast
- **Severity:** Low
- **Category:** Stability / alarm-feed completeness (residual of GWC-01)
- **Location:** `Alarms/GatewayAlarmMonitor.cs:213-214` (subscribe + reconcile) precede the attach at `:232-234`; late-subscriber semantics at `Sessions/SessionEventDistributor.cs:579-582`; `ApplyReconcile` presence-delta-only broadcast at `Alarms/GatewayAlarmMonitor.cs:514-553`
- **Description:** The distributor pump has been running since `MarkReady` (dashboard mirror registers first, `Sessions/GatewaySession.cs:445-449`, `:621`), and a subscriber registered mid-stream misses previously fanned events by design. Alarm transitions arriving between the worker's `SubscribeAlarms` activation and the monitor's internal-subscriber registration (two full worker command round-trips later) are fanned only to the dashboard mirror. Raise/Clear are repaired by the next reconcile, but an Acknowledge in the window updates `_alarms` silently on the next reconcile snapshot replace (`:547-551`) without any `Broadcast` — live `StreamAlarms` subscribers keep showing the alarm unacked until it clears. This is the same defect class GWC-01 fixed, shrunk from "random half of all events, forever" to "a one-shot window of a few hundred ms per monitor lifecycle".
- **Recommendation:** Attach the internal subscriber before invoking `SubscribeAlarmsAsync` (take the lease from `AttachInternalEventSubscriber` explicitly, then subscribe, then drain), or buffer the lease's channel during subscribe/reconcile. Also consider broadcasting an acked-state delta from `ApplyReconcile` when an existing reference's `CurrentState` changes.
### GWC-27 — `AttachInternalEventSubscriber` / `ReadAlarmEventsAsync` bypass the readiness gate; a premature attach permanently poisons the session's event distributor
- **Severity:** Low
- **Category:** Latent lifecycle hazard
- **Location:** `Sessions/GatewaySession.cs:553-562` (no `_state == Ready` check, contrast `AttachEventSubscriber` `:889-916`); `Sessions/SessionManager.cs:195-208` (public `ReadAlarmEventsAsync`, also unchecked)
- **Description:** The internal-attach path starts the distributor pump unconditionally. If invoked before the session is Ready, the pump's source (`MapWorkerEventsAsync``GetReadyWorkerClientAsync`, `:740-749`/`:1910`) throws `SessionNotReady`; `PumpAsync` completes all subscribers with the error and latches `_completed` (`Sessions/SessionEventDistributor.cs:603-612`, `:662-680`). Because `_eventDistributorStarted` is never reset (`Sessions/GatewaySession.cs:527-531`) and the distributor is replaced only in `DisposeAsync`, every later registration — including gRPC `StreamEvents` attaches — receives an immediately-completed channel carrying the stale error: a Ready session with permanently dead event streaming. Not reachable today (the alarm monitor, the only caller, attaches after `OpenSessionAsync` returns a Ready session), but the hazard is one future call site away and fails in the worst way (silent, permanent, per-session).
- **Recommendation:** Mirror `AttachEventSubscriber`'s readiness check at the top of `AttachInternalEventSubscriber` (throw `SessionManagerErrorCode.SessionNotReady` before `EnsureDistributorCreated`).
### GWC-28 — Gateway→worker envelope `sequence` is stamped at envelope creation, not at write, so concurrent invokes can emit non-monotonic sequences on the wire
- **Severity:** Low
- **Category:** IPC contract / conventions
- **Location:** `Workers/WorkerClient.cs:1031-1044` (`CreateEnvelope` stamps `Interlocked.Increment(ref _nextSequence)`); enqueue at `:957-972`; single write loop `:390-409`; the gateway `WorkerFrameWriter` does not re-stamp (`Workers/WorkerFrameWriter.cs:35-77`)
- **Description:** Two concurrent `InvokeAsync` callers can stamp sequences 1 and 2 but win the channel `WriteAsync` race in the order 2, 1, putting out-of-order sequences on the pipe. This is exactly the defect WRK-04 fixed on the worker side (its writer now stamps at the point of writing under the write lock); the gateway half was not given the same treatment. Benign today — nothing validates inbound sequence on either side (GWC-10/IPC-10 both open) — but it violates gateway.md's "monotonic per sender" contract and would start faulting sessions the moment worker-side validation is added.
- **Recommendation:** Move the stamp into `WriteLoopAsync` immediately before `_writer.WriteAsync` (single consumer, naturally serialized), matching the worker's WRK-04 fix; coordinate with GWC-10 if enforcement is added.
### GWC-29 — `Invoke` deep-clones the entire request only to discard the cloned command
- **Severity:** Low
- **Category:** Performance (hot path)
- **Location:** `Grpc/MxAccessGatewayService.cs:119-121`; `Grpc/MxAccessGrpcMapper.cs:27-37`
- **Description:** `MxCommandRequest invokeRequest = request.Clone()` deep-clones the request *including its command payload* (potentially a large bulk-write graph), then immediately overwrites `invokeRequest.Command` with `commandToInvoke`, discarding the cloned command. `MapCommand` then performs the one clone that is actually needed (`request.Command.Clone()`). Net: one full wasted command deep-clone on every `Invoke` — the same cost class GWC-07/IPC-05 eliminated on the event and envelope paths, still present on the command path.
- **Recommendation:** Skip the request clone: add a `MapCommand(MxCommand command)` overload (or construct a minimal request) since `MapCommand` reads only the command, and pass `commandToInvoke` directly.
### GWC-30 — Frame reader still allocates a fresh 4-byte prefix array per frame
- **Severity:** Info
- **Category:** Performance (residual of GWC-08)
- **Location:** `Workers/WorkerFrameReader.cs:33`
- **Description:** The GWC-08 design suggested a per-reader scratch buffer for the length prefix; the payload was pooled but the `new byte[sizeof(uint)]` per frame remains. Gen-0, 4 bytes — negligible; recorded only so the next hot-path pass knows it was seen, not missed.
- **Recommendation:** Optional: a reusable per-reader prefix field (the reader is single-consumer by construction).
## Severity roll-up (new findings)
| Severity | Count | IDs |
|----------|:-:|-----|
| Critical | 0 | — |
| High | 0 | — |
| Medium | 2 | GWC-24, GWC-25 |
| Low | 4 | GWC-26, GWC-27, GWC-28, GWC-29 |
| Info | 1 | GWC-30 |
## Still good / preserved
The qualities the prior review praised survived the remediation churn intact. Session state writes remain single-lock disciplined (`_syncRoot` everywhere, including the new fault-timestamp and faulted-reap paths); the close gate still serializes Close/Kill/sweep with atomic TOCTOU re-checks (`TryBeginCloseIfExpired` gained the faulted arm without weakening the reattach-wins race resolution). The replay→live handoff argument still holds in the circular-array rewrite — the `_replayLock`-atomic snapshot+register in `RegisterWithReplay` is unchanged in structure and its no-gap/no-duplicate documentation is exemplary. The rewritten ring's eviction, ordering, and wraparound logic are correct. The GWC-01 fix is a model remediation: it doesn't just fix the bug, it converts the whole bug class into a loud `InvalidOperationException` via `SingleReader = true` plus the claimed-once guard. Repo invariants were respected throughout: no synthesized events (the `ReplayGap` sentinel is a documented control signal, correctly never fanned or drained elsewhere), one worker per session, and the frame wire format is byte-identical after the pooled-I/O rewrite. Convention adherence (file-scoped namespaces, `sealed`, `Async` suffixes, MXAccess-aligned naming) remains strong in all new code; `GatewaySession` has grown to 2,128 lines and the split-out recommendation from the prior report stands.