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.
34 KiB
Gateway Server Core — Remediation Design & Implementation (2026-07-12 cycle)
Source review: 10-gateway-core.md · Roadmap: 00-overall.md · Generated: 2026-07-13
This document turns the 2026-07-12 re-review's new Gateway Server Core findings (GWC-24 through GWC-30) into buildable remediation entries. Every cited path:line was re-verified against main at 4f5371f. Tiers follow the overall roadmap: GWC-25 is P0.1 (grouped with the client-side CLI-35/36 ReplayGap handling), GWC-24 is P1.6, and GWC-26/27/28 sit in the P2 batch; GWC-29/30 are untiered polish. Prior-cycle findings still open (GWC-05, 09, 10, 11, 12, 13, 16–22) are tracked in archreview/remediation/10-gateway-core.md and are not re-planned here, but two of them (GWC-10, GWC-21) are named below where a new fix should coordinate with them.
Finding index
| ID | Sev | Tier | Eff | Dep | Status | Title |
|---|---|---|---|---|---|---|
| GWC-24 | Medium | P1 | M | GWC-21 (coord) | Not started | Unbounded event staging channel: sustained slow drain grows memory silently and invisibly |
| GWC-25 | Medium | P0 | S | CLI-35/36 (coord) | Not started | Empty-ring ReplayGap sentinel carries oldest_available_sequence = 0, dead-streaming a compliant client |
| GWC-26 | Low | P2 | M | GWC-27 | Not started | Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed, missed Acknowledge never repaired |
| GWC-27 | Low | P2 | S | GWC-26 | Not started | AttachInternalEventSubscriber bypasses the readiness gate; premature attach poisons the distributor permanently |
| GWC-28 | Low | P2 | S | GWC-10 (coord) | Not started | Gateway→worker envelope sequence stamped at creation, not at write — non-monotonic on the wire under concurrent invokes |
| GWC-29 | Low | — | S | — | Not started | Invoke deep-clones the entire request only to discard the cloned command |
| GWC-30 | Info | — | S | — | Not started | Frame reader allocates a fresh 4-byte length-prefix array per frame |
Dependency notes: GWC-26 and GWC-27 both change the internal-subscriber attach path (GatewaySession.AttachInternalEventSubscriber and its SessionManager/alarm-monitor callers) — land GWC-27's readiness gate first (or in the same commit), then GWC-26's reorder, so the reordered monitor attach is proven against the gate. GWC-24 is the direct successor of the prior cycle's GWC-04 backpressure fix and raises the value of the still-open GWC-21 (making EventChannelFullModeTimeout configurable); GWC-28 is the gateway half of the worker's WRK-04 fix and must be coordinated with the still-open GWC-10 if inbound sequence enforcement is ever added. GWC-25 is server-complete on its own, but the end-to-end reconnect story also needs the client-domain CLI-35/36 fixes (Python CLI crashes on the sentinel, Go CLI destroys it).
GWC-24 — Unbounded event staging channel: sustained slow drain grows memory silently and invisibly Medium · P1
Finding. The GWC-04 remediation decoupled the read loop from event backpressure by staging events into _eventStaging, an unbounded channel (Workers/WorkerClient.cs:93-100). StageWorkerEvent's TryWrite therefore always succeeds (:565-573), and the sustained-overflow ProtocolViolation fault fires only when a single timed WriteAsync against the bounded _events exceeds EventChannelFullModeTimeout (default 5 s, :610-647). The queue-depth gauge counts only _events — _eventQueueDepth is incremented in EnqueueWorkerEventAsync (:616, :627) and decremented in ReadEventsCoreAsync (:289), so staged-but-unqueued events are invisible to SetWorkerEventQueueDepth. The field comment (:28-33) claims staging "only fills during the bounded EventChannelFullModeTimeout window", which is true only for a full consumer stall, not for a consumer that drains slower than the worker produces while each individual write still completes inside the window.
Impact. Before GWC-04, a full _events blocked the read loop, which propagated backpressure through the pipe to the worker's own bounded queue — gateway memory per session was structurally bounded end-to-end. Now a slow-but-live distributor pump (each WriteAsync completing in, say, 4 s while the worker emits faster) grows _eventStaging without bound, without any fault, and without any metric showing it. The repo's fail-fast backpressure invariant currently rests on pump liveness (the pump's own fan-out is non-blocking TryWrite, Sessions/SessionEventDistributor.cs:582-594) rather than on channel bounds — unlikely to bite, but no longer structurally impossible, and undiagnosable when it does.
Design. Do both halves of the review's recommendation — restore a structural bound and make the backlog observable:
- Bound the staging channel at
2 × EventChannelCapacity(derived from the existing option; no new configuration key). Total gateway-side buffering per session becomes3 × MxGateway:Events:QueueCapacity(bounded consumer channel + 2× staging), which absorbs the same bursts the unbounded design absorbed in practice while capping the pathological case. Create it withFullMode = BoundedChannelFullMode.WaitsoTryWritereturnsfalsewhen full (the same Wait+TryWrite overflow-detection idiom the distributor documents atSessionEventDistributor.cs:354-361). - Fault immediately on a staging
TryWritemiss. A full staging channel meansEventWriteLoopAsynchas been saturated against a full_eventsfor however long the worker took to emit2 × capacityfurther events — that is the sustained-slow-drain signal, with no timer needed.StageWorkerEventtreats afalsereturn asSetFaulted(ProtocolViolation, …)unless the client is already terminal (a completed channel also returnsfalseduring shutdown — guard with the existingIsTerminalState()so the shutdown path stays a silent drop, as documented today).SetFaultedis non-blocking, so calling it fromDispatchEnvelopeon the read loop preserves the GWC-04 guarantee that the read loop never awaits behind events. Keep the existingEventChannelFullModeTimeouttimed-write fault inEnqueueWorkerEventAsyncunchanged — it catches the full-stall case earlier than the staging bound would. - Unify the queue-depth gauge over both channels. Move the
Interlocked.Increment(ref _eventQueueDepth)+SetWorkerEventQueueDepthfromEnqueueWorkerEventAsync(:616-617,:627-628) toStageWorkerEvent(on successfulTryWrite). The decrement already happens at consumer read (ReadEventsCoreAsync:289), so the single counter then reports total undelivered events (staged + queued) with no second counter and no extra hot-path work. Record_metrics?.QueueOverflow("worker-event-staging")on the staging fault so it is distinguishable from the existing"worker-events"overflow label.
Rejected alternatives: (a) unbounded channel + a total-depth ceiling check — same effect but needs a second configured ceiling and a watermark comparison per event; the bounded channel gives the ceiling for free and fails at exactly the point the invariant is violated. (b) fault-on-sustained-growth (timestamp when the backlog first appears, fault when it persists past a window) — more machinery and a weaker guarantee, since memory still grows throughout the window. Fail-fast is the repo's documented backpressure policy (docs/DesignDecisions.md); a loud session fault with a worker kill is the correct outcome, not silent buffering.
Coordinate with (do not block on) open GWC-21: if EventChannelFullModeTimeout is made configurable in the same pass, the 2× staging multiplier is the natural companion knob; until then the derived constant needs no validator change.
Implementation.
Workers/WorkerClient.csconstructor (:93-100): replaceChannel.CreateUnbounded<WorkerEvent>withChannel.CreateBounded<WorkerEvent>(new BoundedChannelOptions(checked(2 * _options.EventChannelCapacity)) { SingleReader = true, SingleWriter = true, FullMode = BoundedChannelFullMode.Wait, AllowSynchronousContinuations = false }). Rewrite the_eventStagingfield comment (:28-33) — it must state the bound, the overflow fault, and that the depth gauge covers staged + queued.StageWorkerEvent(:565-573): onTryWritesuccess,Interlocked.Increment(ref _eventQueueDepth)+_metrics?.SetWorkerEventQueueDepth(...); on failure,if (IsTerminalState()) return;then_metrics?.QueueOverflow("worker-event-staging")andSetFaulted(WorkerClientErrorCode.ProtocolViolation, …)with a message naming the staging capacity (2 × EventChannelCapacity), the current depth, and the actionable fix (attach/unblock theStreamEventsconsumer or raiseMxGateway:Events:QueueCapacity). Update the XML doc, which currently assertsTryWritealways succeeds.EnqueueWorkerEventAsync(:610-647): remove the two depth increments (now counted at staging); keep the timedWriteAsync, both fault paths, and theVolatile.Readdepth in the overflow message.ReadEventsCoreAsync(:284-293): unchanged — the decrement site is already correct for the unified counter.- Docs, same commit:
docs/MxAccessWorkerInstanceDesign.md/docs/GatewayProcessDesign.mdwherever the GWC-04 read-loop/event-writer decoupling is described (state the staging bound and the two fault modes);docs/GatewayConfiguration.mdunderMxGateway:Events:QueueCapacity(total per-session gateway buffering is 3× the value; overflow of either bound faults the session); the metrics doc if it defines the worker event queue-depth gauge (now includes staged events). - Tests (
Gateway/Workers/WorkerClientTests.cs), new:StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout— smallEventChannelCapacity(e.g. 4) viaWorkerClientOptions, no event consumer attached, longEventChannelFullModeTimeout(e.g. 5 min so the timed-write fault cannot be the trigger); push> 3 × capacityevents through the fake pipe; assert the client faultsProtocolViolationwith the staging message, and that a command reply interleaved before the fault still completed (read loop never stalled).WorkerEventQueueDepthGaugeCountsStagedEvents— no consumer, push N events (N below the staging bound but aboveEventChannelCapacity); assert the gauge reports N (staged + queued); attach the consumer, drain, assert the gauge returns to 0.- Existing GWC-04 tests (reply-not-stalled-behind-events, sustained-overflow fault) must stay green.
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerClientTests". Gateway-side only — no worker change, no windev/x86 needed.
GWC-25 — Empty-ring ReplayGap sentinel carries oldest_available_sequence = 0, dead-streaming a compliant client Medium · P0
Finding. RegisterWithReplay's empty-ring branch sets oldestAvailableSequence = 0 even when gap == true (Sessions/SessionEventDistributor.cs:463-467), and EventStreamService emits that verbatim in the ReplayGap sentinel (Grpc/EventStreamService.cs:133-139, :210-222). The proto contract (src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto, ReplayGap.oldest_available_sequence) instructs a client that wants a gapless resume to send after_worker_sequence = oldest_available_sequence − 1. With oldest = 0, a uint64 client computes 2^64−1; on the next resume RegisterWithReplay replays nothing and reports no gap (the afterSequence == ulong.MaxValue wrap guard at :471/:757 suppresses it), and the live filter mxEvent.WorkerSequence <= afterWorkerSequence (EventStreamService.cs:179) then drops every live event — a silently dead stream. Reachable by default: ReplayRetentionSeconds = 300 age-evicts inside RegisterWithReplay via EvictAged() (:458), and ReplayBufferCapacity = 0 with a behind cursor hits the same branch. All five clients shipped the oldest − 1 formula (CLI-15).
Impact. A client that reconnects after ≥ 5 minutes detached (or against a retention-disabled gateway) and follows the documented resume formula gets a permanently event-less stream with no error — the exact resilience feature (detach-grace + replay, on by default) fails in its headline scenario.
Design. In the empty-ring-with-gap branch, emit oldest_available_sequence = _highestSequenceSeen + 1 — the next sequence that can possibly be delivered. The client's oldest − 1 then equals _highestSequenceSeen: the follow-up resume replays nothing, reports no gap, and the live filter passes every event newer than the highest already seen. Nothing is lost relative to today (the evicted events are unrecoverable either way — the sentinel's whole job is to say "re-snapshot"), and no client changes. Keep 0 for the no-gap case, where the field is documented as meaningless and never emitted. TryGetReplayFrom (:735-774) does not surface an oldest-available value, so it needs no change. Rejected alternative: define 0 as a special "nothing retained" case in the proto and handle it in all five clients — five client releases plus a version bump (CLI-39 is already blocking publishes) versus a one-line server fix that keeps the single resume formula universal.
The proto comment currently states "oldest_available_sequence itself IS still retained", which becomes false in the empty-ring case — per the docs-with-source rule, amend the field comment in the same commit to define the empty-ring value ("when nothing is retained, this is the next sequence that can be delivered — highest observed + 1 — and the oldest − 1 resume formula remains valid; the interval evicted is unchanged"). This is a comment-only proto change (no descriptor delta), but the repo's codegen rules still apply — see the steps.
Implementation.
Sessions/SessionEventDistributor.cs:463-467: replaceoldestAvailableSequence = 0;witholdestAvailableSequence = gap ? _highestSequenceSeen + 1 : 0;plus a comment explaining theoldest − 1client formula this must keep valid (cite this finding).src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto(ReplayGap.oldest_available_sequence, ~line 759): append the empty-ring sentence above. Then regenerate per repo rules: deletesrc/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs,dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj, and commitGenerated/(net48 worker builds break otherwise). Sync the vendored client copies of the proto byte-identical (clients/*/); a comment-only edit changes no descriptor, so: Python*_pb2*output is unchanged (comments are not embedded — regenerate with the pinned grpcio-tools only if the files actually differ), Go/C#/Rust generated doc comments will churn — regenerate those per each client README, and revert spurious Java aggregate-file churn if no message-level delta appears (per the established Java convention).docs/Sessions.md(~lines 228-234, ReplayGap section): document the empty-ring sentinel value and thatafter_worker_sequence = oldest_available_sequence − 1is the universal resume formula in both the retained and fully-evicted cases.- Tests, new:
Gateway/Sessions/SessionEventDistributorTests.cs—RegisterWithReplayReportsNextDeliverableSequenceWhenRingEmptiedByAge: FakeTimeProvider, capacity > 0, short retention; pump events up to highest sequence H; advance the clock past retention;RegisterWithReplay(afterSequence: 1, …)→ assertsgap == true,oldestAvailableSequence == H + 1,replayedEventsempty,liveResumeSequence == 1.RegisterWithReplayReportsNextDeliverableSequenceWithRetentionDisabled: capacity-0 internal ctor overload, events seen to H, resume behind →gap == true,oldestAvailableSequence == H + 1.ResumeUsingSentinelFormulaAfterEmptyRingGapDeliversLiveEvents: after the gap above, re-register withafterSequence = (H + 1) − 1 = H→ no gap, no replay, and a newly pumped eventH + 1is delivered on the live channel — the direct regression test for the dead-stream failure.Gateway/GatewayEndToEndReconnectReplayTests.cs—ReconnectAfterFullAgeEvictionResumesWithSentinelFormula: fake-worker e2e; stream, detach, evict everything by advancing time pastReplayRetentionSeconds, reconnect with the last-seen watermark, applyoldest − 1from the received sentinel on the follow-up resume, assert new live events arrive.
Cross-domain: the client-side halves — Python CLI crashing on the sentinel (CLI-35) and the Go CLI destroying it (CLI-36) — are planned in the clients remediation doc; this server fix is independent and safe to land first (roadmap P0.1 groups all three).
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx (includes the contracts regen); dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~SessionEventDistributorTests" and --filter "FullyQualifiedName~GatewayEndToEndReconnectReplay". If the proto comment lands: the committed Generated/ keeps the net48 worker buildable, but an actual x86 worker build/test run needs windev — flag as the standard windev verification (comment-only change, so functional risk is nil).
GWC-26 — Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed, missed Acknowledge never repaired Low · P2
Finding. RunMonitorAsync invokes SubscribeAlarmsAsync and the first ReconcileAsync (Alarms/GatewayAlarmMonitor.cs:213-214) before the internal distributor subscriber is attached inside _sessionManager.ReadAlarmEventsAsync (:232-234 → Sessions/SessionManager.cs:195-208 → GatewaySession.AttachInternalEventSubscriber, Sessions/GatewaySession.cs:553-562). The distributor pump has been running since MarkReady started the dashboard mirror (GatewaySession.cs:445-449, :621), and a late subscriber misses previously fanned events by design (SessionEventDistributor.cs:579-582). Raise/Clear lost in the window are repaired by the next reconcile, but ApplyReconcile broadcasts only presence deltas (GatewayAlarmMonitor.cs:514-553): an Acknowledge that lands in the window updates _alarms silently on the snapshot replace (:547-551) with no Broadcast, so live StreamAlarms subscribers show the alarm unacked until it clears.
Impact. A one-shot window of a few hundred ms (two worker command round-trips) per monitor lifecycle in which alarm transitions reach only the dashboard mirror; a missed Acknowledge is never broadcast to alarm-feed subscribers. Same defect class as the fixed GWC-01, shrunk from permanent to transient.
Design. Two independent halves:
- Attach before subscribing. The monitor already holds the
GatewaySessioninstance returned byOpenSessionAsync(:203-208), so take the internal lease directly —session.AttachInternalEventSubscriber()— beforeSubscribeAlarmsAsync, and enumeratelease.Reader.ReadAllAsync(...)after reconcile. Transitions arriving during subscribe + reconcile simply buffer in the lease's bounded channel (MxGateway:Events:QueueCapacity, default 1024 — ample for a two-round-trip window); if it ever overflowed, the internal subscriber is merely disconnected (never faults the session, per theisInternalcontract), the enumeration ends, and the monitor's supervisor loop restarts — acceptable and loud. Processing buffered transitions afterApplyReconcileis order-safe:ApplyTransitionhandles alarms already present in the reconciled snapshot. With the monitor calling the session directly,ISessionManager.ReadAlarmEventsAsyncloses its only caller — remove it rather than leave dead public surface (mirrors the GWC-19 precedent). Rejected alternative: buffer insideReadAlarmEventsAsyncby splitting attach/enumerate on the manager — same effect, but keeps an indirection whose only job was to hide the session object the monitor already has. - Repair acked-state in reconcile. In
ApplyReconcile, for references present in both_alarmsand the incoming snapshot, broadcast anAcknowledgefeed transition when the state changed to acked (existing.CurrentState != incoming.CurrentState && incoming.CurrentState == AlarmConditionState.ActiveAcked), via the existingTransitionFromSnapshot(incoming, AlarmTransitionKind.Acknowledge). This is defense-in-depth for any future missed window (worker restart, overflow disconnect), not just this one. Invariant note:ApplyReconcilealready broadcasts snapshot-derived Raise/Clear transitions on the alarm feed (AlarmFeedMessage, the StreamAlarms/dashboard surface — see the method comment "broadcasting a synthetic transition for any alarm the live stream missed"); extending that established feed-level repair to acked-state deltas does not touch the gRPCStreamEventspath and therefore does not violate the "never synthesize events" rule, which governsMxEventemission.
Implementation.
Alarms/GatewayAlarmMonitor.csRunMonitorAsync(:211-250): afterOpenSessionAsync/_sessionpublication,using IEventSubscriberLease alarmLease = session.AttachInternalEventSubscriber();beforeSubscribeAlarmsAsync(...); replace the_sessionManager.ReadAlarmEventsAsync(...)enumeration withawait foreach (MxEvent mxEvent in alarmLease.Reader.ReadAllAsync(linked.Token)); update the:228-231comment to state the attach-before-subscribe ordering and why.Sessions/SessionManager.cs:195-208andISessionManager: removeReadAlarmEventsAsync(re-verify zero remaining callers with a grep at implementation time; repoint any test that used it atGatewaySession.AttachInternalEventSubscriber).Alarms/GatewayAlarmMonitor.csApplyReconcile(:514-553): before the snapshot replace, add the both-present acked-delta loop described above.- Docs, same commit:
docs/Sessions.md's note that the alarm monitor attaches as an internal distributor subscriber (added by the GWC-01 fix) — extend with "attached before SubscribeAlarms so no transition window is missed";gateway.md's alarm-monitor section if it describes the startup ordering. - Tests:
GatewayAlarmMonitorAttachOrderTests(new, or extend the existing alarm monitor tests inAlarms/): fake worker emits anAcknowledge(and aRaise) transition immediately after theSubscribeAlarmsreply and before the first reconcile reply; assert aStreamAlarmssubscriber observes both — the direct regression test for the window.ApplyReconcileBroadcastsAcknowledgeDelta(unit): seed the cache with anActivealarm, reconcile with the same referenceActiveAcked; assert exactly oneAcknowledgefeed message and no Raise/Clear.- Existing alarm monitor +
SessionManagerTestssuites stay green after theReadAlarmEventsAsyncremoval.
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayAlarmMonitor" and --filter "FullyQualifiedName~SessionManagerTests". Depends on GWC-27 landing first (or together) so the earlier attach point runs against the readiness gate.
GWC-27 — AttachInternalEventSubscriber bypasses the readiness gate; premature attach poisons the distributor permanently Low · P2
Finding. GatewaySession.AttachInternalEventSubscriber (Sessions/GatewaySession.cs:553-562) runs EnsureDistributorCreated → Register(isInternal: true) → StartPumpIfRequested with no state check, unlike AttachEventSubscriber, which requires _state == Ready && _workerClient?.State == Ready under _syncRoot (:889-896). SessionManager.ReadAlarmEventsAsync (Sessions/SessionManager.cs:195-208) exposes the unchecked path publicly. If invoked before Ready, the pump's source (MapWorkerEventsAsync → GetReadyWorkerClientAsync, :740-749, :1910) throws SessionNotReady; PumpAsync completes all subscribers with the error and latches _completed (SessionEventDistributor.cs:603-612, :662-680). _eventDistributorStarted is never reset (GatewaySession.cs:527-531) and the distributor is replaced only in DisposeAsync, so every later registration — including gRPC StreamEvents — receives an immediately-completed channel carrying the stale error: a Ready session with permanently dead event streaming. Unreachable today (the alarm monitor attaches only after OpenSessionAsync returns Ready), but one future call site away and failing in the worst way (silent, permanent, per-session).
Impact. Latent lifecycle hazard: any refactor or new caller that attaches internally before Ready silently kills event streaming for that session's whole lifetime, with no fault and no log pointing at the cause.
Design. Mirror AttachEventSubscriber's readiness gate at the top of AttachInternalEventSubscriber: under _syncRoot, throw SessionManagerException(SessionManagerErrorCode.SessionNotReady, …) when _state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready, before EnsureDistributorCreated — so a premature attach fails loudly and never constructs/starts the distributor. Rejected alternative: make the distributor resettable after a pump fault — a much larger lifecycle change (replay ring, _completed latch, dashboard mirror lease all assume one distributor per session) that would also mask caller bugs instead of surfacing them. StartDashboardMirror keeps its direct distributor.Register(isInternal: true) path — it is called from MarkReady under its own state guard (:604) and is not a public entry point.
Implementation.
Sessions/GatewaySession.cs:553-562: add the_syncRootreadiness check (reuse the exact exception code and message shape fromAttachEventSubscriber:891-896); then proceed with the existingEnsureDistributorCreated/Register/StartPumpIfRequestedsequence outside the lock (matchingAttachEventSubscriber's lock discipline — the check is under the lock, the distributor calls are not). Extend the XML remarks to state the gate.Sessions/SessionManager.cs:195-208: no separate check needed — the path inherits the gate via the session call, and GWC-26 removes the method entirely.- Tests (
Gateway/Sessions/GatewaySessionTests.cs), newAttachInternalEventSubscriberBeforeReadyThrowsAndDoesNotPoisonDistributor: on a not-yet-Ready session, assertSessionManagerException/SessionNotReady; then drive the session to Ready and assertAttachEventSubscriberstill yields a live stream (the key assertion: the distributor was never created/started by the failed attach).
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewaySessionTests". Land before (or with) GWC-26.
GWC-28 — Gateway→worker envelope sequence stamped at creation, not at write Low · P2
Finding. CreateEnvelope stamps Sequence = (ulong)Interlocked.Increment(ref _nextSequence) at envelope construction (Workers/WorkerClient.cs:1031-1044); envelopes are then enqueued to _outboundEnvelopes (EnqueueAsync:957-972) and written by the single WriteLoopAsync (:390-409); WorkerFrameWriter does not re-stamp. Two concurrent InvokeAsync callers can stamp 1 and 2 but enqueue in the order 2, 1 — non-monotonic sequences on the pipe, violating gateway.md's "monotonic per sender" contract. This is exactly what WRK-04 fixed on the worker side (stamp at the point of writing, under the writer's serialization); the gateway half was not given the same treatment. Benign today — neither side validates inbound sequence (GWC-10/IPC-10 open) — but it would start faulting sessions the moment worker-side validation is added.
Impact. Contract violation lying in wait: adding the specified sequence enforcement on the worker side (the mirror of GWC-10) would immediately fault healthy sessions under concurrent command load.
Design. Stamp at write, matching WRK-04: WriteLoopAsync assigns envelope.Sequence immediately before _writer.WriteAsync. The write loop is the channel's single consumer (SingleReader = true, :74), so writes are naturally serialized — the counter becomes a plain ulong incremented only on the write loop, and the Interlocked goes away. Every outbound envelope flows through _outboundEnvelopes (hello :150, commands :223, shutdown :317), so coverage is total. Rejected alternative: re-stamp inside WorkerFrameWriter — the frame writer is a pure framing layer shared with the FakeWorkerHarness and tests; giving it protocol state would leak envelope semantics into the wire layer on both sides.
Implementation.
Workers/WorkerClient.cs:1031-1044(CreateEnvelope): remove theSequenceassignment; add a comment that the sequence is stamped inWriteLoopAsyncat the point of writing (cite WRK-04 parity and this finding).Workers/WorkerClient.cs:390-409(WriteLoopAsync): before_writer.WriteAsync(envelope, …),envelope.Sequence = ++_nextSequence;— change the_nextSequencefield (:37) fromlong+Interlockedto a plainulongtouched only by the write loop, and update its usage accordingly.- Docs, same commit: the
gateway.mdenvelope-sequence paragraph (the "monotonic per sender" contract, ~line 314) — note both sides stamp at write under their single write path; cross-reference open GWC-10 for the (still absent) inbound enforcement. - Tests (
Gateway/Workers/WorkerClientTests.cs), newConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire: fake pipe; fire N (e.g. 32) parallelInvokeAsynccalls; capture the frames the "worker" side receives; assert theSequencevalues are strictly increasing in wire order. Existing handshake/shutdown tests confirm hello/shutdown envelopes still carry sequences.
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerClientTests" and --filter "FullyQualifiedName~WorkerFrameProtocolTests". Gateway-side only; the worker's own WRK-04 stamping is untouched.
GWC-29 — Invoke deep-clones the entire request only to discard the cloned command Low · —
Finding. MxAccessGatewayService.Invoke runs MxCommandRequest invokeRequest = request.Clone(); invokeRequest.Command = commandToInvoke; (Grpc/MxAccessGatewayService.cs:119-120) — a deep clone of the whole request including its command payload (potentially a large bulk-write graph), whose cloned command is immediately overwritten and discarded. MapCommand then performs the one clone actually needed (request.Command.Clone(), Grpc/MxAccessGrpcMapper.cs:27-37). Net cost: one full wasted command deep-clone per Invoke — the same cost class GWC-07/IPC-05 eliminated on the event and envelope paths.
Impact. Avoidable allocation and copy proportional to command size on every command, worst for exactly the bulk writes that are largest.
Design. Skip the request clone entirely: add a MapCommand(MxCommand command) overload that builds the WorkerCommand from the command alone (Command = command.Clone(), EnqueueTimestamp as today) — MapCommand reads nothing else from the request. Invoke passes commandToInvoke directly. Keep the existing MapCommand(MxCommandRequest) overload delegating to the new one so other callers and tests are untouched. The single remaining clone is still required and must stay: commandToInvoke may be the caller-owned request.Command (gRPC-owned graph) and is also read after the invoke by session.TrackCommandReply(commandToInvoke, …) (MxAccessGatewayService.cs:132), so ownership transfer (à la GWC-07) is not safe here — the clone in MapCommand is what keeps WorkerClient.CreateCommandEnvelope's documented no-aliasing invariant (Workers/WorkerClient.cs:1000-1004) true. That comment stays accurate unchanged.
Implementation.
Grpc/MxAccessGrpcMapper.cs: addpublic WorkerCommand MapCommand(MxCommand command)(null-check, clone, timestamp); retargetMapCommand(MxCommandRequest request)to validate and delegate.Grpc/MxAccessGatewayService.cs:119-121: delete therequest.Clone()and theinvokeRequestlocal;WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);.- Tests:
Gateway/Grpc/MxAccessGrpcMapperTests.cs— newMapCommandFromCommandClonesPayload(mutating the returnedWorkerCommand.Commanddoes not affect the input command;EnqueueTimestamppopulated; both overloads produce equal results). ExistingMxAccessGatewayServiceTestsandMxAccessGatewayServiceConstraintTestsmust stay green — the constraint path (bulkConstraintPlan.Commandflowing through, denied-merge on the reply) is the behavior most worth re-running. - Docs: none (internal hot-path change; no contract or config surface).
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~MxAccessGrpcMapperTests" and --filter "FullyQualifiedName~MxAccessGatewayService".
GWC-30 — Frame reader allocates a fresh 4-byte length-prefix array per frame Info · —
Finding. WorkerFrameReader.ReadAsync allocates new byte[sizeof(uint)] per frame (Workers/WorkerFrameReader.cs:33); the GWC-08 pass pooled the payload buffer but left the prefix. Gen-0, 4 bytes — negligible; recorded so the next hot-path pass knows it was seen.
Impact. One tiny short-lived allocation per inbound frame. No functional risk.
Design. A reusable per-reader scratch field: private readonly byte[] _lengthPrefix = new byte[sizeof(uint)];. The reader is single-consumer by construction — exactly one read loop per WorkerClient calls ReadAsync, never concurrently (handshake reads happen before the read loop starts) — so a per-instance buffer is safe; document that ReadAsync is not reentrant. Rejected: pooling 4 bytes via ArrayPool — rent/return overhead exceeds the allocation it saves.
Implementation.
Workers/WorkerFrameReader.cs: add the_lengthPrefixfield, use it at:33-36, and add a one-line class remark thatReadAsyncmust not be invoked concurrently (single-consumer contract).- Tests: existing
Gateway/Workers/WorkerFrameProtocolTests.csround-trips cover it; add (if absent) a sequential multi-frame read on one reader instance asserting all frames parse — guarding the shared-scratch reuse. - Docs: none.
Verification. dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx; dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerFrameProtocolTests".