fix(WRK-21): make drain budget monotonic at the reserve boundary; guard the reply-too-large fallback
ci / windows-x86 (push) Successful in 1m16s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m17s
ci / portable (push) Successful in 9m31s

Review follow-ups on the WRK-21 cluster.

1. ResolveDrainReplyByteBudget was a step function, not a floor: just above the
   64 KiB reserve the budget collapsed to a few bytes (at the validator-permitted
   floor MaxMessageBytes = 1024 + 64 KiB it was exactly 1024), too small to move a
   byte-heavy event, so DrainEvents truncated on every call and the drain-until-
   empty loop never terminated. It now takes the max of (frameMax - reserve) and
   frameMax/2, so the budget is monotonic and never below half the frame max. New
   test DrainEvents_AtValidatorFloorFrameMax_MakesProgressAndTerminates drives a
   byte-heavy queue at the exact validator floor and asserts it drains to empty
   with no head ever reported oversized.

2. The reply-too-large fallback write is now itself size-guarded
   (WriteReplyTooLargeFallbackAsync, used by both the control and STA reply seams):
   at a pathologically tiny negotiated max below the gateway's floor the fallback
   could also throw MessageTooLarge and — uncaught — kill the session, defeating the
   "no diagnostics command is session-fatal" invariant. It now log-and-swallows;
   comment notes WRK-24 adds the negotiated-max lower bound that makes it unreachable.

3. Corrected the RepeatedFieldOverheadBytes doc comments: WorkerEvent.CalculateSize()
   already includes the event's tag and length prefix (the same shape the reply's
   repeated events field packs), so the 8 bytes is pure slack over an already-
   conservative estimate, not compensation for a missing wrapper.
This commit is contained in:
Joseph Doherty
2026-08-07 07:09:13 -04:00
parent c9925688f5
commit 6bc3f9b991
3 changed files with 134 additions and 17 deletions
@@ -26,9 +26,13 @@ public sealed class MxAccessEventQueue
/// </summary>
public const int DefaultCapacity = 10000;
// Per-event allowance added to WorkerEvent.CalculateSize() when charging the byte budget in
// Drain(maxEvents, maxTotalBytes): conservatively covers the repeated-field tag byte and the
// length varint the event costs once packed into DrainEventsReply.
// Extra per-event slack added to WorkerEvent.CalculateSize() when charging the byte budget in
// Drain(maxEvents, maxTotalBytes). CalculateSize() already accounts for the event's own tag and
// length-delimiter (the WorkerEvent wrapper serializes MxEvent as field 1, and the reply packs
// each MxEvent as DrainEventsReply.events field 1 with the identical tag+length shape), so this
// is a pure safety margin over an already-conservative estimate — not compensation for a missing
// wrapper. It keeps the running total strictly ahead of the true serialized size so a rounding
// edge can never push the packed reply past the frame maximum.
private const int RepeatedFieldOverheadBytes = 8;
private readonly int capacity;
@@ -221,10 +225,11 @@ public sealed class MxAccessEventQueue
/// <remarks>
/// The size decision happens inside the queue lock, so an event is dequeued only once it is
/// known to fit: an event that does not fit stays at the head for the next call and is never
/// lost (WRK-21). Per-event cost is <c>WorkerEvent.CalculateSize()</c> plus
/// <see cref="RepeatedFieldOverheadBytes"/>; the <see cref="WorkerEvent"/> wrapper slightly
/// overestimates the packed <c>MxEvent</c> and the constant conservatively covers the
/// repeated-field tag and length varint, so the estimate errs strictly on the safe side.
/// lost (WRK-21). Per-event cost is <c>WorkerEvent.CalculateSize()</c> — which already
/// includes the event's own tag and length prefix, the same shape the reply's
/// <c>events</c> repeated field packs it into — plus <see cref="RepeatedFieldOverheadBytes"/>
/// of pure slack, so the running total stays strictly ahead of the true serialized size and
/// the estimate errs on the safe side.
/// </remarks>
/// <param name="maxEvents">Maximum number of events to drain; 0 means "no count limit".</param>
/// <param name="maxTotalBytes">Byte budget for the drained events' estimated serialized size.</param>