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.
39 KiB
Worker Process — Remediation Design & Implementation (2026-07-12 cycle)
Source review: 20-worker.md · Roadmap: 00-overall.md · Generated: 2026-07-13 · Baseline: main @ 4f5371f
This cycle's findings are concentrated in the new frame-write scheduler and the DrainEvents bound that the prior remediation added — the scheduler is fundamentally sound, and every fix below is an edge-hardening of it rather than a redesign. One finding is P0 (WRK-21, shared with IPC-23: a diagnostics command can still kill the session); the rest are Lows plus two residuals of prior Done items (WRK-25, WRK-26). All fixes preserve MXAccess parity (no MXAccess behavior changes — these are all IPC/liveness-layer changes), preserve the control-before-event write-priority guarantee, and use net48-safe constructs only (plain constructors and get-only properties, no init-only members, no positional records). The worker builds and tests only on the Windows x86 host (windev); verification commands assume that host.
Finding register
| ID | Sev | Tier | Eff | Dep | Status | Title |
|---|---|---|---|---|---|---|
| WRK-21 | Medium | P0 | M | IPC-23 (same defect, fix owned here); WRK-28 (same lines) | Not started | DrainEvents bound is count-based only; an oversized reply still kills the session and loses the drained events |
| WRK-22 | Low | — | S | IPC-26 (same defect, fix owned here) | Not started | Cancelled WriteAsync leaves its frame queued; it is still written later |
| WRK-23 | Low | — | S | WRK-21 (rejection path becomes backstop-only) | Not started | Rejected frames consume sequence numbers, producing wire gaps |
| WRK-24 | Low | — | S | — | Not started | AdoptNegotiatedMaxMessageBytes has no lower-bound sanity check |
| WRK-25 | Low | P2 | S | WRK-22 (both touch enqueue/dequeue) | Not started | WRK-12 flush coalescing never engages on the event hot path |
| WRK-26 | Low | P1 | S | WRK-23 (soft — sequence prose); discharges IPC-29 | Not started | Write-priority and overflow doc drift from the WRK-07 change |
| WRK-27 | Low | — | S | — | Not started | Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) |
| WRK-28 | Low | — | S | WRK-21 (land in the same commit cluster) | Not started | 10,000 drain cap is a duplicated magic constant with a comment-only sync contract |
WRK-21 — DrainEvents bound is count-based only; an oversized reply still kills the session and loses the drained events Medium · P0
Finding. CreateDrainEventsReply clamps only the event count to MaxDrainEventsPerReply
(10,000) (src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs:545-557); the reply is written
via WriteControlReplyAsync (:481, :485-496) with no catch. A reply whose serialized size exceeds
the negotiated MaxMessageBytes throws WorkerFrameProtocolException(MessageTooLarge) from
WorkerFrameWriter.WriteFrameAsync (Ipc/WorkerFrameWriter.cs:250-255) — correctly a per-frame
rejection at the writer, but HandleControlCommandAsync/DispatchGatewayEnvelopeAsync propagate
it out of RunMessageLoopAsync, so the session dies and WorkerApplication.cs:110-119 exits
ProtocolViolation (6). The 10,000 drained events were already removed from MxAccessEventQueue
by runtimeSession.DrainEvents(maxEvents) (:551) and are gone. 10,000 events at ~1.7 KiB each
(large string/array MxValues) exceed the default 16 MiB + 64 KiB frame max, so the comment's
stated goal ("cannot pack the whole queue into one session-killing reply frame", :21-26) is not met
for byte-heavy events. This is the same defect as IPC-23; this entry owns the single fix (the
IPC remediation register should point here). Roadmap P0 item 2: byte-aware DrainEvents reply
capping such that no diagnostics command can be session-fatal.
Impact. A DrainEvents max_events=0 diagnostic against a byte-heavy queue — the array-heavy
payloads this gateway exists for — terminates a healthy session (exit 6, gateway kills the worker)
and silently destroys the drained events. The exact "session-killing reply frame" the prior fix
(IPC-04/WRK slice) was added to prevent, narrowed but not closed.
Design. Two layers, both required by the P0 acceptance criterion:
-
Primary — byte-aware drain that never removes an event it cannot ship. The reply must be sized while draining, and the size decision must happen inside the queue's lock so events are only dequeued once they are known to fit. Add a byte-budgeted overload to
MxAccessEventQueue: drain from the head whilecount < maxEventsand the accumulated serialized size plus the next event's size stays within a byte budget; an event that does not fit is left at the head for the next call. The budget is_options.MaxMessageBytes(the negotiated value, read at reply-build time) minus a fixed headroom constant covering the envelope/reply wrapper overhead (64 KiB — the same envelope-overhead reserve rationaledocs/WorkerFrameProtocol.mdalready documents for the frame max itself). Per-event cost isWorkerEvent.CalculateSize() + 8— theWorkerEventwrapper slightly overestimates the packedMxEvent, and +8 conservatively covers the repeated-field tag and length varint, so the estimate errs strictly on the safe side. Truncation is reported in the reply's existingDiagnosticMessagefield ("N events returned, M remain; repeat DrainEvents for the rest"), so no proto change is needed.Degenerate case: a single head event whose serialized size alone exceeds the budget. It is not drained (draining it would either build an oversized reply or lose it); the reply returns whatever fit before it (possibly zero events) with a
DiagnosticMessagenaming the blocked event'sWorkerSequenceso the operator can find the offending tag. Such an event cannot be shipped on the streaming path either — that policy question is IPC-30 and stays out of scope here; the WRK-21 guarantee is only that DrainEvents never dies and never loses events. -
Backstop — the control-reply write seam must not be session-fatal. Even with capping (a future command, a sizing bug), catch
WorkerFrameProtocolExceptionwithErrorCode == MessageTooLargeat the two reply-write seams and answer the correlation with a small error reply instead of unwinding the session:HandleControlCommandAsync'sWriteControlReplyAsynccall (:481) andProcessCommandAsync's reply write (:630-638), which today converts any write failure into_state = Faulted+MxaccessCommandFailed— i.e. an oversized STA command reply also faults the whole session. The error reply usesProtocolStatusCode.InvalidRequestwith an explanatory message ("reply exceeded the negotiated frame maximum; retry with a smaller request").
Rejected alternatives: (a) adding truncated/remaining_count fields to DrainEventsReply — a
contracts change that regenerates and ripples through all five language clients for a diagnostic
command; DiagnosticMessage carries the same information at zero contract cost. (b) Catch-only
(no byte capping) — survives the session but still loses the already-drained events, failing the
"no event loss" half. (c) Draining into the reply and measuring reply.CalculateSize()
periodically — protobuf C# does not memoize sizes, making that O(n²), and events over the line
would already be dequeued. (d) A new ProtocolStatusCode.ReplyTooLarge enum value — proto change
for a should-be-unreachable backstop; rejected for the same ripple reason as (a).
Parity note: DrainEvents is a gateway-protocol diagnostic, not an MXAccess call — no MXAccess behavior changes. Control-before-event priority is untouched (the reply stays a Control frame).
Implementation.
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WorkerEventDrainResult.cs(new): sealed class, plain constructor + get-only properties (net48-safe):IReadOnlyList<WorkerEvent> Events,bool TruncatedBySize,int RemainingCount,ulong OversizedHeadSequence(0 = none).MxAccess/MxAccessEventQueue.cs: addpublic WorkerEventDrainResult Drain(uint maxEvents, int maxTotalBytes)— undersyncRoot, loopevents.Peek(), computeworkerEvent.CalculateSize() + 8, dequeue while it fits the remaining budget anddrained.Count < maxEvents(0 = existing "all" semantics for the count dimension); on a head event that alone exceedsmaxTotalBytes, stop and record itsWorkerSequenceintoOversizedHeadSequence;RemainingCount = events.Counton exit.MxAccess/IWorkerRuntimeSession.cs: addWorkerEventDrainResult DrainEvents(uint maxEvents, int maxTotalBytes);(no default interface method — the worker is net48, which has no DIM runtime support). Implement inMxAccess/MxAccessStaSession.cs(delegate to the queue) and in every test fake that implements the interface (the fake runtime session inWorker.Tests/Ipc/WorkerPipeSessionTests.cs).Ipc/WorkerPipeSession.cs:- add
private const int DrainReplyFrameHeadroomBytes = 64 * 1024;next toMaxDrainEventsPerReply(:26) with a comment tying it to the envelope-overhead reserve; CreateDrainEventsReply(:537-562): call the byte-budgeted overload with_options.MaxMessageBytes - DrainReplyFrameHeadroomBytes; whenTruncatedBySize, setreply.DiagnosticMessagewith returned/remaining counts and, whenOversizedHeadSequence != 0, the blocked sequence;HandleControlCommandAsync(:481): wrapWriteControlReplyAsync(reply, ...)intry/catch (WorkerFrameProtocolException ex) when (ex.ErrorCode == WorkerFrameProtocolErrorCode.MessageTooLarge)→ logWorkerControlReplyTooLarge(correlation id, kind, payload size from the exception message) and write a fallbackMxCommandReply(same correlation id/kind,ProtocolStatusCode.InvalidRequest, explanatory message) viaWriteControlReplyAsync;ProcessCommandAsync(:630-638): put the same catch around the reply write specifically (inner try), writing the fallback error reply for the correlation instead of falling into the generic catch that sets_state = WorkerState.Faulted.
- add
- Fold WRK-28 into this commit cluster (same lines — see below).
- Docs same commit:
docs/MxAccessWorkerInstanceDesign.md— the control-command/DrainEvents prose gains the byte cap, the truncationDiagnosticMessagecontract, and the oversized-head-event behavior; note in the Outbound Queues section that no control reply is session-fatal on size.docs/WorkerFrameProtocol.md— one sentence in the size-limits section: the session pre-sizes DrainEvents replies below the negotiated max, andMessageTooLargeon a reply write is answered with an error reply, not session teardown.
Verification. On windev:
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
then
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerPipeSessionTests|FullyQualifiedName~MxAccessEventQueueTests".
New tests:
WorkerPipeSessionTests.DrainEvents_ByteHeavyQueue_ReplyIsBoundedAndSessionSurvives— the review's named repro: enqueue 10,000 events carrying large string values (≥ ~1.7 KiB each) into a realMxAccessEventQueuebehind the fake runtime, sendDrainEvents max_events=0; assert the reply envelope's serialized size ≤MaxMessageBytes,DiagnosticMessagereports truncation, and the session still answers a subsequentPing(no fault frame,RunAsyncstill running).WorkerPipeSessionTests.DrainEvents_RepeatedCalls_RecoverAllEventsWithoutLoss— issue DrainEvents until empty; assert the union of replies is exactly the 10,000 enqueued events in order (split/bounded replies, zero loss).WorkerPipeSessionTests.ControlReplyTooLarge_WritesErrorReplyInsteadOfDying— shrink the negotiated max viaWorkerFrameProtocolOptionsso the reply write throwsMessageTooLarge; assert a fallbackInvalidRequestreply with the same correlation id is written and the session survives.WorkerPipeSessionTests.CommandReplyTooLarge_WritesErrorReplyInsteadOfFaulting— same via the STA command path (ProcessCommandAsync); assert noWorkerFault,_statestaysReady.MxAccessEventQueueTests.Drain_ByteBudget_StopsBeforeBudgetAndLeavesRemainderQueued— order preserved,RemainingCountexact, undrained events still present.MxAccessEventQueueTests.Drain_ByteBudget_OversizedHead_DrainsNothingAndReportsHeadSequence.
WRK-22 — Cancelled WriteAsync leaves its frame queued; it is still written later Low · —
Finding. WorkerFrameWriter.WriteAsync enqueues the PendingFrame before the cancellable
lock wait (Ipc/WorkerFrameWriter.cs:87-103); if _writeLock.WaitAsync(cancellationToken)
throws, nothing removes the frame, so the next lock-holder writes it. Concrete scenario: at
shutdown the event-drain loop's token is cancelled (Ipc/WorkerPipeSession.cs:298-304) while its
event frame waits for the lock; the shutdown-ack write then drains queues — ack first (Control),
then the orphaned event — so an event frame is emitted after WorkerShutdownAck, and a write
reported as cancelled to its caller still reaches the wire. Same defect as IPC-26 (the IPC
review describes this writer); this entry owns the single fix.
Impact. Benign today (the gateway drops post-shutdown/unknown frames), but the "cancelled means not written" contract is silently violated, an unobserved completion lingers, and any future gateway-side strictness about frames after the ack turns this into a protocol violation.
Design. Tombstone on cancellation, claim on dequeue. Queue<T> has no random removal, so the
standard fix is: the cancelled caller marks its frame cancelled under _gate; DequeueNext skips
completed (cancelled) frames and atomically marks the frame it returns as claimed. A frame
already claimed by a draining lock-holder is mid-write and can no longer be recalled — the
documented contract becomes "cancellation guarantees the frame is not written if it has not yet
been claimed; a claimed frame may still reach the wire." That residual window is unavoidable
without blocking the canceller behind the very write it is abandoning (rejected: it inverts the
point of cancellation). Rejected alternative: switching to a LinkedList for O(1) removal —
tombstoning is simpler, allocation-free, and the queues are short-lived.
Implementation.
Ipc/WorkerFrameWriter.cs, nestedPendingFrame: addpublic bool Claimed;(plain field, mutated only under_gate).WriteAsync(envelope, priority, ct)(:100-113): wrapawait _writeLock.WaitAsync(cancellationToken)intry/catch (OperationCanceledException)→lock (_gate) { if (!frame.Claimed) { frame.Completion.TrySetCanceled(cancellationToken); } }then rethrow. (If already claimed, the frame will be written; the caller still observes the cancellation — documented in the method's XML doc.)DequeueNext(:200-216): under_gate, loop-dequeue past frames whoseCompletion.Task.IsCanceled; setClaimed = trueon the frame actually returned.FailAllQueued/FailFramesalready useTrySetException, which no-ops on cancelled frames.- Docs same commit: the "Write scheduling and sequencing" section of
docs/WorkerFrameProtocol.md(created by WRK-26 — if WRK-26 has not landed yet, carry the cancellation paragraph in this commit and let WRK-26 absorb it) states the cancelled-means-not-written-unless-claimed contract.
Verification. On windev:
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerFrameProtocolTests".
New tests:
WorkerFrameProtocolTests.WriteAsync_CancelledWhileWaitingForLock_FrameIsNeverWritten— block the stream (gated fake stream) so writer A holds_writeLockmid-write; enqueue an event write with a CTS; cancel; assertOperationCanceledException; unblock A; write a control frame; assert the captured wire bytes contain A's frame and the control frame only — the cancelled event envelope never appears.WorkerFrameProtocolTests.WriteAsync_CancelledEventFrame_DoesNotTrailShutdownAck— the review's shutdown scenario: cancelled event frame queued, then a Control (ack-shaped) write drains; assert the event frame is skipped and the ack is the last frame on the wire.
WRK-23 — Rejected frames consume sequence numbers, producing wire gaps Low · —
Finding. WorkerFrameWriter.WriteFrameAsync stamps envelope.Sequence = unchecked(++_nextSequence)
(Ipc/WorkerFrameWriter.cs:240) before the empty-payload and MessageTooLarge checks
(:243-255), so a per-frame rejection leaves a hole in the on-wire sequence. gateway.md:328 calls
sequence a monotonic diagnostic counter, so nothing breaks — but the existing test
WorkerFrameProtocolTests.WriteAsync_UnderConcurrentCalls_StampsGapFreeMonotonicSequence
documents a stronger gap-free expectation than the code guarantees once a rejection occurs.
Impact. Diagnostics only: an operator correlating wire captures sees phantom gaps after any rejection and may chase a lost-frame bug that does not exist; the test asserts a guarantee the code does not hold.
Design. Peek-stamp, validate, commit. The sequence value participates in CalculateSize()
(varint width), so it cannot simply be stamped after the size checks — the checked size would not
match the written payload. Instead compute a candidate (_nextSequence + 1), stamp it, run the
size/empty checks against the stamped envelope, and only commit _nextSequence = candidate
immediately before the stream write. On a per-frame rejection the counter is untouched, so the
next accepted frame reuses the number and the wire stays contiguous. Safe because _nextSequence
is only ever touched by the lock-holder (:44-48). The rejected envelope retains its tentative
sequence — a caller-visible mutation on a failed envelope that already exists today. Rejected
alternative: decrement on rejection (--_nextSequence) — equivalent effect but leaves a window
where the counter was observably advanced; the candidate/commit form has no intermediate state.
Dependency note: after WRK-21 lands, MessageTooLarge at the writer becomes a rare backstop, so
this fix is what makes the existing gap-free test's guarantee genuinely true rather than
usually-true.
Implementation.
Ipc/WorkerFrameWriter.cs,WriteFrameAsync(:234-267): keepWorkerEnvelopeValidator.Validatefirst; replace the stamp withulong candidate = unchecked(_nextSequence + 1); envelope.Sequence = candidate;; after the empty-payload andMaxMessageByteschecks pass,_nextSequence = candidate;immediately before the buffer build/WriteTo. Update the :45-48 comment.- Docs same commit: none required beyond WRK-26's scheduling section, which should state "rejected frames do not consume sequence numbers" once this lands (see WRK-26 ordering note).
Verification. On windev:
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerFrameProtocolTests".
New test:
WorkerFrameProtocolTests.WriteAsync_PerFrameRejection_DoesNotConsumeSequence— write an accepted frame, attempt an oversized frame (assertMessageTooLargerejection), write another accepted frame; assert the two on-wire frames carry sequences 1 and 2. The existingWriteAsync_UnderConcurrentCalls_StampsGapFreeMonotonicSequencemust still pass.
WRK-24 — AdoptNegotiatedMaxMessageBytes has no lower-bound sanity check Low · —
Finding. Ipc/WorkerFrameProtocolOptions.cs:124-140 — 0 keeps the default and >256 MiB
throws, but any value from 1 byte up is adopted. A gateway bug or a foreign/old gateway sending
e.g. 512 leaves the session alive but unable to write any frame (every write fails
MessageTooLarge) and unable to read most inbound frames — failing confusingly mid-session rather
than at the handshake, contradicting the class's own declared intent (:14-18, "a nonsensical
negotiated value is rejected at the handshake"). The gateway's own validator floors its config at
1024 (src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:10,230-233).
Impact. A misconfigured or buggy peer produces a session that handshakes cleanly and then fails every subsequent operation with per-frame size errors — the worst diagnostic shape for an operator, and exactly what the top-end check was added to prevent.
Design. Mirror the gateway's validation floor: reject negotiated values below 1024 with the
same InvalidConfiguration error the ceiling uses, so the failure is a handshake-time fault frame
(ValidateGatewayHello → CompleteStartupHandshakeAsync's WorkerFrameProtocolException catch →
TryWriteFaultAsync + throw) instead of a mid-session mystery. Floor value: 1024, matching
GatewayOptionsValidator.MinimumMaxMessageBytes — the worker must never reject a value the
gateway's own validator accepts as legal configuration. Rejected alternative: a 64 KiB floor
(review's other suggestion) — safer-sounding but would turn a gateway legitimately configured
anywhere in [1024, 65535] into a handshake failure; symmetry of the two processes' validation
bounds is the defensible contract, and 1024 already guarantees hellos, heartbeats, acks, and
faults fit.
Implementation.
Ipc/WorkerFrameProtocolOptions.cs: addpublic const int MinNegotiableFrameBytes = 1024;(XML doc: matches the gateway'sMinimumMaxMessageBytesvalidation floor); inAdoptNegotiatedMaxMessageBytes, after the== 0early return, throwWorkerFrameProtocolException(InvalidConfiguration)whennegotiatedMaxFrameBytes < MinNegotiableFrameBytes, message symmetrical with the ceiling text.- Docs same commit:
docs/WorkerFrameProtocol.mdnegotiated-max paragraph (:25-30) — state the accepted range [1024, 256 MiB], 0 = keep default, out-of-range rejected at the handshake.
Verification. On windev:
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerFrameProtocolTests|FullyQualifiedName~WorkerPipeSessionTests".
New tests:
WorkerFrameProtocolTests.AdoptNegotiatedMaxMessageBytes_BelowFloor_ThrowsInvalidConfiguration(512 → throws; 1024 → adopted; boundary pair).WorkerPipeSessionTests.Handshake_GatewayHelloWithTinyMaxFrameBytes_FaultsAtHandshake— mirror of the existing >256 MiB handshake test:GatewayHello.MaxFrameBytes = 512→ fault frame written, handshake throws, no message loop entered.
WRK-25 — WRK-12 flush coalescing never engages on the event hot path Low · P2
Finding. The event drain loop awaits each event's WriteAsync — which completes only after
that frame is written and flushed — before writing the next
(Ipc/WorkerPipeSession.cs:374-382), so the writer's event queue holds at most one frame from the
drain loop and every event still costs one flush. The batch flush path
(Ipc/WorkerFrameWriter.cs:120-124, 160-182) only coalesces when independent producers happen to
queue behind a blocked in-progress write. Residual of prior Done item WRK-12: the tracking-log
claim "a burst of N events costs 1 flush not N" does not describe production behavior.
Impact. Performance only: under event bursts (the gateway's core load) each event pays a semaphore round-trip and a pipe flush; the shipped coalescing mechanism sits idle on the exact path it was built for.
Design. Give the drain loop a real batch entry point: WriteBatchAsync on the writer —
enqueue every frame of the drained batch under one _gate acquisition (preserving intra-batch
order), take the write lock once, drain (existing logic: control-first, one flush per batch),
then await all completions. The existing single-drainer machinery then does exactly what WRK-12
intended: N events, one flush. Guarantees preserved: control-before-event holds because the batch
still lands in _eventFrames and any concurrently queued control frame is drained first by
DequeueNext; intra-batch event order holds because the enqueue is atomic under _gate and the
event queue is FIFO; the "written and flushed before completion" contract is unchanged. A
per-frame rejection inside the batch (an oversized single event) surfaces from the awaited
completions and terminates the session exactly as today — that policy is IPC-30's, deliberately
not changed here. Rejected alternative (review's other option): fire N WriteAsync calls and
Task.WhenAll — works (each call enqueues synchronously before its first await) but costs N
semaphore acquisitions and N no-op drains for zero benefit over the explicit batch. Depends on
WRK-22 landing first or together: both touch the enqueue/DequeueNext seam, and batch cancellation
reuses WRK-22's tombstoning.
Implementation.
Ipc/WorkerFrameWriter.cs: addpublic async Task WriteBatchAsync(IReadOnlyList<WorkerEnvelope> envelopes, WorkerFrameWritePriority priority, CancellationToken cancellationToken = default)— build aPendingFrame[], enqueue all under onelock (_gate), singleawait _writeLock.WaitAsync(cancellationToken)(onOperationCanceledException: tombstone all unclaimed frames per WRK-22, rethrow),DrainQueuedFramesAsync(), release, then await each completion in order (plain loop over the array — no LINQ needed, net48-safe either way).Ipc/WorkerPipeSession.cs,RunEventDrainLoopAsync(:374-382): replace the per-event awaited loop with building the envelope list for the drained batch and oneWriteBatchAsync(envelopes, WorkerFrameWritePriority.Event, cancellationToken). Keep the priority comment, updated.- Docs same commit:
docs/WorkerFrameProtocol.mdscheduling section (WRK-26, which lands earlier as P1) — amend its flush-coalescing sentence to say the event drain loop submits drained batches through the batch entry point, so a burst of N events costs one flush.
Verification. On windev:
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerFrameProtocolTests|FullyQualifiedName~WorkerPipeSessionTests".
New tests:
WorkerFrameProtocolTests.WriteBatchAsync_FlushesOnceAndPreservesOrder— flush-counting fake stream; N event envelopes → exactly one flush, wire order = batch order, sequences monotonic.WorkerFrameProtocolTests.WriteBatchAsync_ControlFrameQueuedDuringBatch_JumpsRemainingEvents— control-before-event still holds mid-batch.WorkerPipeSessionTests.EventBurst_DrainLoopCoalescesFlushes— fake runtime yields a 128-event batch; assert the capturing stream saw one flush for the batch (this is the assertion the WRK-12 tracking claim needs to actually be true).
WRK-26 — Write-priority and overflow doc drift from the WRK-07 change Low · P1
Finding. docs/MxAccessWorkerInstanceDesign.md:607-613 still specifies the five-level write
priority (faults > replies > shutdown acks > heartbeats > events) while the implementation is a
two-class scheduler (Control FIFO, then Event — Ipc/WorkerFrameWritePriority.cs:10-17,
Ipc/WorkerFrameWriter.cs:200-216); within Control a fault does not jump queued
heartbeats/replies. docs/WorkerFrameProtocol.md describes the negotiated max (:25-30) but says
nothing about priority classes, flush coalescing, write-time sequence stamping, or the per-frame
vs stream-failure semantics. The adjacent overflow-policy text
(docs/MxAccessWorkerInstanceDesign.md:615-624, "stop accepting new commands") is also stale
against the implemented fail-fast (WRK-18's doc half). CLAUDE.md requires affected docs to change
in the same commit as the source; this is the residual of Done item WRK-07. Roadmap P1 doc-drift
batch (item 8); discharges IPC-29, which asks for the same WorkerFrameProtocol.md section.
Impact. The component design doc actively misleads: a maintainer adding a new frame kind will look for a five-level queue that does not exist, and the accepted FIFO-within-control decision is recorded nowhere, so it reads as a defect rather than a choice.
Design. Doc-only; make the docs describe the implemented scheduler and record the decision. No code change — the two-class scheduler is correct and accepted (the review's deep-dive found it deadlock-free and lost-wakeup-free; within-Control FIFO is bounded by the small control queue depth). Ordering note: this is P1 and will likely land before WRK-23/WRK-25 (P2/untierred), so the new text must describe HEAD truthfully at commit time — sequence numbers are consumed by rejected frames until WRK-23 lands, and event flushes are not yet coalesced on the drain path until WRK-25 lands; write those two facts as they are, and WRK-23/WRK-25 update the sentences in their own commits (same-commit docs rule applies to them too).
Implementation.
docs/MxAccessWorkerInstanceDesign.md:602-624: rewrite the "Outbound Queues" section — two-class cooperative scheduler (WorkerFrameWriter+WorkerFrameWritePriority): Control (faults, command replies, shutdown acks, heartbeats, hello/ready) FIFO among themselves and always ahead of Event frames; enqueue-then-contend, lock-holder drains all queued frames; record the accepted decision that the original five-level order was collapsed and why (control queue is shallow, so intra-control FIFO delay is bounded and the machinery stays simple). Rewrite the overflow paragraph to the implemented fail-fast (fault written, drain loop throws, process exits — currently the generic exit code; WRK-18 still tracks the dedicated code).docs/WorkerFrameProtocol.md: add a "Write scheduling and sequencing" section — priority classes;Sequencestamped at the moment of writing under the write lock so wire order and stamped sequence always agree; per-frame rejections (InvalidEnvelope,MessageTooLarge, version/session mismatch) fail only that frame while stream failures fail the batch and all queued frames; flush coalescing across a drained batch and the "written and flushed" completion contract; the rejection/sequence-gap behavior as it stands at commit time (see ordering note); cancellation semantics once WRK-22 lands (or carried by WRK-22's commit if it lands later).- Cross-check
gateway.md:328-330(sequence prose) still reads true; touch only if wrong.
Verification. Doc-only — no build impact. Sanity on any host:
grep -n "faults" docs/MxAccessWorkerInstanceDesign.md shows no remaining five-level list;
grep -n "scheduling" docs/WorkerFrameProtocol.md finds the new section. Follow
docs/style-guides/StyleGuide.md (present tense, explain why). Confirm the IPC remediation
register marks IPC-29 as discharged by this item.
WRK-27 — Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) Low · —
Finding. MxAccess/MxAccessStaSession.cs:245-253 invokes handler.PollOnce() via
staRuntime.InvokeAsync directly, bypassing StaCommandDispatcher; CaptureHeartbeat takes
CurrentCommandCorrelationId only from the dispatcher (:364-381), so during a long PollOnce the
heartbeat shows no in-flight command and the watchdog fires StaHung as soon as staleness exceeds
HeartbeatGrace (15 s default) rather than the 75 s HeartbeatStuckCeiling granted to dispatched
commands (Ipc/WorkerPipeSession.cs:843-861).
Impact. A healthy-but-slow GetXmlCurrentAlarms2 (large alarm set, busy provider) blocking
the STA for >15 s faults a healthy session. Partially defensible — a blocked STA cannot deliver
data events either — but the 15 s vs 75 s asymmetry between polls and commands is unintentional.
Design. Surface poll-in-progress to the watchdog through the heartbeat snapshot as a boolean,
and have the suppression branch honor it alongside CurrentCommandCorrelationId. The poll then
gets exactly the command treatment: suppressed up to the ceiling, faulted past it (a poll that
blocks the STA >75 s without pumping should fault — that is the ceiling's contract). All types
touched are worker-internal; the heartbeat proto is unchanged, so no contract/client work.
Rejected alternatives: (a) routing the poll through StaCommandDispatcher — inflates
PendingCommandCount, subjects polls to the 128-entry command backpressure and
shutdown-rejection semantics, and perturbs dispatch ordering for real gateway commands; the poll
is infrastructure, not a command. (b) A synthetic correlation id (e.g. "alarm-poll") in the
heartbeat — visible to the gateway, which treats correlation ids as real command identifiers
(dashboards, per-command timeout logic); misrepresenting a nonexistent command crosses an
observability contract for a purely local suppression need.
Implementation.
MxAccess/MxAccessStaSession.cs: addprivate volatile bool staAlarmPollInProgress;; in the delegate passed tostaRuntime.InvokeAsyncinRunAlarmPollLoopAsync(:247-253), set ittrueimmediately beforeEnsureOnAlarmConsumerThread()/handler.PollOnce()and clear it in afinally— the flag flips on the STA thread exactly around the COM call.MxAccess/WorkerRuntimeHeartbeatSnapshot.cs: add abool StaCallInProgressget-only property and constructor parameter (plain ctor assignment — net48-safe; update all construction sites, including tests). Named generically ("an STA call outside the dispatcher is executing") so any future non-dispatcher STA work reuses it.MxAccess/MxAccessStaSession.CaptureHeartbeat(:364-381): passstaAlarmPollInProgress.Ipc/WorkerPipeSession.ReportWatchdogFaultIfNeededAsync(:850-861): change the suppression condition to(!string.IsNullOrEmpty(snapshot.CurrentCommandCorrelationId) || snapshot.StaCallInProgress) && staleFor <= _sessionOptions.HeartbeatStuckCeiling; extend the branch comment.- Docs same commit:
docs/MxAccessWorkerInstanceDesign.mdwatchdog section (~:656-702) — one paragraph: the alarm poll runs outside the dispatcher, advertises itself via the snapshot's STA-call-in-progress flag, and receives the same grace-to-ceiling suppression as dispatched commands.
Verification. On windev:
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
then
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerPipeSessionTests|FullyQualifiedName~MxAccessStaSessionTests".
New tests:
WorkerPipeSessionTests.Watchdog_StaCallInProgress_SuppressedUntilCeiling— fake runtime returns a snapshot with activity stale beyondHeartbeatGracebut within the ceiling, empty correlation id,StaCallInProgress = true; assert noStaHungfault; then a snapshot stale beyond the ceiling; assert the fault fires (parity with the existing command-suppression tests).MxAccessStaSessionTests.CaptureHeartbeat_DuringAlarmPoll_ReportsStaCallInProgress— blockPollOnceon a gate in a stub alarm handler, capture a heartbeat mid-poll, assert the flag; release, assert it clears.
WRK-28 — 10,000 drain cap is a duplicated magic constant with a comment-only sync contract Low · —
Finding. Ipc/WorkerPipeSession.cs:26 (MaxDrainEventsPerReply = 10_000, "Kept in step with
that public ceiling") vs src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:13
(MaxDrainEventsPerRequest = 10_000). Both projects already reference the Contracts assembly
(net10.0;net48), which could own the constant.
Impact. Conventions/maintenance: nothing enforces the sync; the next person to tune one side silently desynchronizes the gateway's loud rejection threshold from the worker's clamp.
Design. Move the ceiling into the Contracts project next to the existing protocol constants in
GatewayContractInfo (src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs) and reference
it from both sides. A C# const only — no .proto change, so no generated-code regeneration
and no language-client impact (the cap is documented behavior, not wire schema). Land in the same
commit cluster as WRK-21, which rewrites the exact worker lines that consume it. Rejected
alternative: leaving two constants with a unit test asserting equality — works, but the shared
constant removes the failure mode instead of detecting it.
Implementation.
src/ZB.MOM.WW.MxGateway.Contracts/GatewayContractInfo.cs: addpublic const uint MaxDrainEventsPerCommand = 10_000;with XML doc naming both consumers (the gateway's request-validation ceiling and the worker's per-reply clamp) and the byte-cap caveat from WRK-21 (count is necessary, not sufficient).src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs:13,81-85: delete the private const; referenceGatewayContractInfo.MaxDrainEventsPerCommand(error text unchanged).src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs:26,545-557: deleteMaxDrainEventsPerReply; reference the shared constant; trim the "kept in step" comment to point at the shared home.- Docs same commit: none required (no behavior change); if
docs/GatewayConfiguration.mdor the DrainEvents prose names the literal 10,000, point it at the shared constant's home.
Verification. Both sides build: on macOS
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx and
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~MxAccessGrpcRequestValidator";
on windev
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
and
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WorkerPipeSessionTests".
Existing drain-bound tests on both sides are the regression guard; no new tests needed beyond
WRK-21's.
Cross-references and sequencing
- WRK-21 = IPC-23 (single defect, single fix — owned here; the IPC register should link to
this entry). Its
ProcessCommandAsyncbackstop narrows command replies only; the oversized event-stream frame policy remains IPC-30 (out of scope here — a delivery-expectations decision, not a mechanics fix). - WRK-22 = IPC-26 (single defect, single fix — owned here). WRK-25 builds on WRK-22's claim/tombstone machinery — land WRK-22 first or together.
- WRK-23 interacts with WRK-21: byte capping makes writer-level
MessageTooLargea rare backstop, and WRK-23 then makes the gap-free sequence guarantee (already asserted by an existing test) actually hold across that backstop. - WRK-26 (P1) lands before the P2/untierred code items — its text must describe HEAD at commit time; WRK-23 and WRK-25 each update the affected sentences in their own commits per the same-commit docs rule. WRK-26 also discharges IPC-29.
- Suggested commit clusters: (1) WRK-21 + WRK-28 [P0]; (2) WRK-26 [P1 doc batch, with TST-27 /
CLI-42 per roadmap item 8]; (3) WRK-22 + WRK-25 [writer seam]; (4) WRK-23; (5) WRK-24; (6)
WRK-27. Run the full worker suite on windev once per cluster batch, not per task
(
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86). - Prior-cycle open items touching the same code (WRK-02/03/05/08/09/10/13/14/16/17/18/19 —
confirmed still open by this review) remain tracked in
archreview/remediation/20-worker.md; WRK-26 deliberately folds in WRK-18's stale overflow prose but leaves its exit-code half open.