Merge fix/c3-ipc-boundaries: IPC-33 drain budget bounded under the gRPC send cap + validator upper coupling, IPC-39 pre-check sequence allowance, IPC-34 WorkerCancel on caller cancellation + CallerCancelled code, IPC-35 handshake WorkerFault surfaces category

This commit is contained in:
Joseph Doherty
2026-08-20 09:33:55 -04:00
15 changed files with 430 additions and 62 deletions
+11 -3
View File
@@ -123,7 +123,7 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
| `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve. The default is the 16 MiB public gRPC cap plus that reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. |
| `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve **and at most that cap plus twice the reserve**. The default is the 16 MiB public gRPC cap plus one reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. The band is narrow by design, so changing either message-size knob alone no longer validates — move `MxGateway:Protocol:MaxGrpcMessageBytes` and this one together, keeping this one one to two reserves above it. |
`StartupProbeRetryAttempts`, `StartupProbeRetryDelayMilliseconds`,
`PipeConnectAttemptTimeoutMilliseconds`, timeout values, heartbeat values, and
@@ -132,7 +132,15 @@ to avoid accidental large allocations from malformed or oversized frames. It
must also stay above `MaxGrpcMessageBytes` by the envelope-overhead reserve so a
maximally-sized accepted gRPC payload always fits one worker frame once wrapped
in a `WorkerEnvelope`; otherwise the oversized outbound write would fault the
whole session. Startup validation fails fast when the headroom is violated.
whole session. The coupling is two-sided: `MaxMessageBytes` may exceed
`MaxGrpcMessageBytes` by at most *twice* the reserve, because the worker budgets
a `DrainEvents` reply against the frame maximum less two reserves and the
gateway then returns that same `MxCommandReply` through `Invoke`, where the gRPC
send cap applies. Without the upper bound a legal configuration (large pipe
maximum, stock gRPC cap) lets the worker pack a reply the pipe carries and the
gRPC layer refuses to send — with the drained events already removed from the
worker queue and therefore lost to both the caller and the event stream. Startup
validation fails fast when either half of the headroom rule is violated.
## Session Options
@@ -385,7 +393,7 @@ is **not yet available**.
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Protocol:WorkerProtocolVersion` | `1` | Worker IPC protocol version expected by the gateway and worker. This must match `GatewayContractInfo.WorkerProtocolVersion`. |
| `MxGateway:Protocol:MaxGrpcMessageBytes` | `16777216` | Public gRPC max send and receive message size in bytes. The same default is used by official clients. The validator allows values from `1024` through `268435456`, and requires `MxGateway:Worker:MaxMessageBytes` to exceed this by at least the 64 KiB worker-frame envelope reserve (see the Worker options). |
| `MxGateway:Protocol:MaxGrpcMessageBytes` | `16777216` | Public gRPC max send and receive message size in bytes. The same default is used by official clients. The validator allows values from `1024` through `268435456`, and requires `MxGateway:Worker:MaxMessageBytes` to exceed this by at least the 64 KiB worker-frame envelope reserve and by no more than twice that reserve, so a `DrainEvents` reply budgeted at the pipe maximum still fits this send cap (see the Worker options). |
The protocol option is exposed for diagnostics and explicit deployment
configuration, not for compatibility negotiation. A mismatch fails validation
+17 -6
View File
@@ -593,9 +593,19 @@ Pending command handling:
Timeouts should not assume the COM call stopped. A timed-out command may still
finish inside the worker.
On timeout the client also forwards a `WorkerCancel` carrying the abandoned
correlation id, best-effort: the gateway has stopped waiting, but the worker has
not stopped working, and the worker owns a single STA. `WorkerPipeSession` routes
On timeout **and on caller cancellation** the client also forwards a
`WorkerCancel` carrying the abandoned correlation id, best-effort: the gateway
has stopped waiting, but the worker has not stopped working, and the worker owns
a single STA. Caller cancellation — a per-call deadline shorter than the
gateway's command timeout, or a client disconnect cancelling the RPC's token — is
the more common of the two paths, and under a retry storm it is exactly the
backlog the cancel exists to shorten, so both arms send it; the pending command
is failed as `CallerCancelled` rather than `GatewayShutdown` so metrics and logs
name the real cause (both still map to gRPC `Cancelled`). The send is gated on
the same ownership check as the timeout arm — only the arm that removed the
pending entry sends, so a reply that won the race is never chased — and is
skipped when the client's own stop token has fired, since the outbound channel is
being completed and every pending command is failed anyway. `WorkerPipeSession` routes
the cancel to `CancelCommand`, which drops the correlation from the STA queue if
it has not started and replies `Canceled` for it. A cancel that arrives after the
command reached MXAccess is a no-op — there is no way to abort an in-flight COM
@@ -606,9 +616,10 @@ the worker reads the command and then its cancel and drops it before execution.
Cancels ride the same outbound channel as commands, whose capacity is
`MaxPendingCommands + 4`: the reserve above the pending-command limit is what
absorbs them, so a burst of timeouts stays bounded and cannot deadlock the
enqueue path. Failing to send the cancel is logged at debug and never replaces
the `CommandTimeout` the caller is owed.
absorbs them, so a burst of timeouts or cancellations stays bounded and cannot
deadlock the enqueue path. Failing to send the cancel is logged at debug and
never replaces the `CommandTimeout` — or the `OperationCanceledException` — the
caller is owed.
Cancellation outranks the deadline. When a caller's token is canceled around the
same time the timeout fires, the command is reported as canceled
+1 -1
View File
@@ -282,7 +282,7 @@ StatusCode statusCode = exception.ErrorCode switch
};
```
`WorkerClientException` follows the same pattern: `CommandTimeout` becomes `DeadlineExceeded`, `GatewayShutdown` becomes `Cancelled`, `InvalidState` becomes `FailedPrecondition`, `ProtocolViolation` becomes `Internal`, `CommandTooLarge` becomes `ResourceExhausted`, and unmapped codes fall through to `Unavailable`.
`WorkerClientException` follows the same pattern: `CommandTimeout` becomes `DeadlineExceeded`, `GatewayShutdown` and `CallerCancelled` both become `Cancelled`, `InvalidState` becomes `FailedPrecondition`, `ProtocolViolation` becomes `Internal`, `CommandTooLarge` becomes `ResourceExhausted`, and unmapped codes fall through to `Unavailable`. `CallerCancelled` is the caller's own token firing — a per-call deadline shorter than the gateway command timeout, or a disconnect — and is separate from `GatewayShutdown` only so metrics and logs stop attributing those calls to a shutdown that did not happen; the status the client observes is the same.
## Event Streaming Model
+17 -3
View File
@@ -43,9 +43,23 @@ guarantees hellos, heartbeats, acks, and faults fit.
Every worker-to-gateway frame must serialize within this limit, control replies
included, so reply builders truncate to fit rather than emit a frame the writer
will reject. `WorkerPipeSession` pre-sizes a `DrainEvents` reply below the
negotiated maximum (less a fixed envelope/reply-wrapper reserve) and reports the
truncation in the reply's `DiagnosticMessage`; the caller contract is to repeat
`DrainEvents` until it returns an empty reply. Should a reply still overshoot,
negotiated maximum and reports the truncation in the reply's
`DiagnosticMessage`; the caller contract is to repeat `DrainEvents` until it
returns an empty reply. The event budget is `max(MaxMessageBytes - 2 x 64 KiB,
MaxMessageBytes / 2)`: the reserve is subtracted **twice** because the reply has
two boundaries to clear, not one. The first reserve covers the
`WorkerEnvelope`/`WorkerCommandReply`/`MxCommandReply` wrapper on the pipe; the
second exists because the gateway returns that same `MxCommandReply` verbatim
through `Invoke`, where the public gRPC send cap applies. `DrainEvents` removes
the events it packs from the worker queue, so a reply the pipe carries and the
gRPC layer then refuses would lose them for good — the gateway's validator
therefore also caps `MxGateway:Worker:MaxMessageBytes` at
`MxGateway:Protocol:MaxGrpcMessageBytes` plus **two** reserves, which is what
makes the doubled subtraction sufficient rather than merely generous. The
`MaxMessageBytes / 2` floor keeps the budget usable at frame maxima small enough
that two reserves would consume them (below 256 KiB), where a collapsed budget
would truncate every drain and never let the drain-until-empty caller finish.
Should a reply still overshoot,
`MessageTooLarge` at the reply-write seam is answered with a small
`InvalidRequest` reply for that correlation, not with session teardown — no
diagnostics command may kill a session.