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
@@ -27,7 +27,8 @@ public sealed class WorkerPipeSession
// Headroom subtracted from the negotiated frame maximum when budgeting a DrainEvents reply. It
// covers the WorkerEnvelope/WorkerCommandReply/MxCommandReply wrapper the drained events are
// packed into — the same envelope-overhead reserve rationale docs/WorkerFrameProtocol.md
// records for the frame max itself.
// records for the frame max itself. ResolveDrainReplyByteBudget subtracts it twice: once for the
// pipe frame, once so the reply also clears the public gRPC send cap it is forwarded through.
private const int DrainReplyFrameHeadroomBytes = 64 * 1024;
private readonly WorkerFrameProtocolOptions _options;
@@ -1021,20 +1022,33 @@ public sealed class WorkerPipeSession
/// <summary>
/// Byte budget for the events packed into one DrainEvents reply: the negotiated frame
/// maximum less a fixed reserve for the envelope/reply wrapper, but never below half the
/// negotiated maximum. The lower bound must be a floor, not a step: a bare
/// <c>subtract-then-guard-positive</c> collapses the budget to a handful of bytes just above
/// the reserve (e.g. at the validator-permitted floor MaxMessageBytes = 1024 + 64 KiB the
/// subtraction leaves 1024, too small to move even one byte-heavy event, so every drain
/// truncates and the drain-until-empty caller never terminates). Taking the max with
/// half the negotiated maximum keeps the budget monotonic across the reserve boundary while
/// still leaving the full reserve for the wrapper whenever the frame max is large enough
/// that the reserve is the smaller subtraction — which is every configuration above 128 KiB.
/// maximum less <em>two</em> reserves, but never below half the negotiated maximum. The
/// first reserve covers the envelope/reply wrapper the events are packed into so the reply
/// fits one pipe frame. The second reserve exists because that same
/// <c>MxCommandReply</c> does not stop at the pipe: the gateway returns it verbatim through
/// <c>Invoke</c>, where the public gRPC send cap applies, and the gateway's validator
/// couples the two limits so the pipe maximum never exceeds the gRPC cap by more than two
/// reserves (IPC-33). Subtracting both therefore keeps the packed events at or below the
/// gRPC cap and leaves the wrapper — correlation id, protocol status, kind, the
/// <c>drain_events</c> length prefix and, in the truncated case, the
/// <c>DiagnosticMessage</c> — a full reserve of margin under it. Budgeting against the pipe
/// maximum alone let a legal configuration build a reply the pipe carried and the gRPC layer
/// then refused to send, with the events already dequeued and therefore lost.
/// <para>
/// The lower bound must be a floor, not a step: a bare <c>subtract-then-guard-positive</c>
/// collapses the budget to a handful of bytes (or below zero) once the reserves approach the
/// frame maximum (e.g. at the validator-permitted floor MaxMessageBytes = 1024 + 64 KiB),
/// too small to move even one byte-heavy event, so every drain truncates and the
/// drain-until-empty caller never terminates. Taking the max with half the negotiated
/// maximum keeps the budget monotonic across the reserve boundary while still leaving both
/// reserves for the wrapper whenever the frame max is large enough that the subtraction is
/// the smaller of the two — which is every configuration above 256 KiB.
/// </para>
/// </summary>
private int ResolveDrainReplyByteBudget()
{
return Math.Max(
_options.MaxMessageBytes - DrainReplyFrameHeadroomBytes,
_options.MaxMessageBytes - (2 * DrainReplyFrameHeadroomBytes),
_options.MaxMessageBytes / 2);
}