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.
22 KiB
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 designarchreview/remediation/10-gateway-core.md; status sourcearchreview/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 MxEvents 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(StageWorkerEventTryWritealways succeeds),:610-647(fault fires only when a single timed write exceedsEventChannelFullModeTimeout),:616/:627(_eventQueueDepthgauge counts_eventsonly) - Description: Before GWC-04, a full
_eventschannel 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 onlyEventWriteLoopAsyncfaces the bounded_events. The sustained-overflow fault fires only when oneWriteAsyncwaits longer than 5 s; if the consumer (the distributor pump) drains slower than the worker produces but each individual write completes within the window,_eventStaginggrows without bound and without any fault — and the growth is invisible, becauseSetWorkerEventQueueDepthreflects 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-subscriberTryWritefan-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 aTryWritemiss there as an immediateProtocolViolationfault (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 == 0branch:oldestAvailableSequence = 0); emitted verbatim byGrpc/EventStreamService.cs:133-139+:210-222; contract atsrc/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:755-763 - Description: The proto contract states "
oldest_available_sequenceitself IS still retained: a client that wishes to resume without incurring another gap MUST setafter_worker_sequence = oldest_available_sequence - 1". When a resume finds a gap with an empty ring (all entries age-evicted — defaultReplayRetentionSeconds = 300andEvictAgedruns insideRegisterWithReplayat:458— orReplayBufferCapacity = 0with the cursor behind_highestSequenceSeen), the sentinel carriesoldest_available_sequence = 0. A contract-compliant client computes0 − 1on a uint64 →2^64−1, and its next resume passesafter_worker_sequence = ulong.MaxValue:RegisterWithReplayreplays nothing, reports no gap, and the live filtermxEvent.WorkerSequence <= afterWorkerSequence(Grpc/EventStreamService.cs:179) then drops every live event — a silently dead stream with no error. All five clients shipped theoldest − 1resume 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), sooldest − 1 = highestSeenresumes 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 atSessions/SessionEventDistributor.cs:579-582;ApplyReconcilepresence-delta-only broadcast atAlarms/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'sSubscribeAlarmsactivation 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_alarmssilently on the next reconcile snapshot replace (:547-551) without anyBroadcast— liveStreamAlarmssubscribers 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 fromAttachInternalEventSubscriberexplicitly, then subscribe, then drain), or buffer the lease's channel during subscribe/reconcile. Also consider broadcasting an acked-state delta fromApplyReconcilewhen an existing reference'sCurrentStatechanges.
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 == Readycheck, contrastAttachEventSubscriber:889-916);Sessions/SessionManager.cs:195-208(publicReadAlarmEventsAsync, 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) throwsSessionNotReady;PumpAsynccompletes all subscribers with the error and latches_completed(Sessions/SessionEventDistributor.cs:603-612,:662-680). Because_eventDistributorStartedis never reset (Sessions/GatewaySession.cs:527-531) and the distributor is replaced only inDisposeAsync, every later registration — including gRPCStreamEventsattaches — 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 afterOpenSessionAsyncreturns 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 ofAttachInternalEventSubscriber(throwSessionManagerErrorCode.SessionNotReadybeforeEnsureDistributorCreated).
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(CreateEnvelopestampsInterlocked.Increment(ref _nextSequence)); enqueue at:957-972; single write loop:390-409; the gatewayWorkerFrameWriterdoes not re-stamp (Workers/WorkerFrameWriter.cs:35-77) - Description: Two concurrent
InvokeAsynccallers can stamp sequences 1 and 2 but win the channelWriteAsyncrace 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
WriteLoopAsyncimmediately 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 overwritesinvokeRequest.CommandwithcommandToInvoke, discarding the cloned command.MapCommandthen performs the one clone that is actually needed (request.Command.Clone()). Net: one full wasted command deep-clone on everyInvoke— 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) sinceMapCommandreads only the command, and passcommandToInvokedirectly.
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.