Inbound WebSocket flow control — shipped 2026-08-31
A design note from the engineering record, moved out of ROADMAP.md on 2026-09-03 and kept as written. The roadmap itself now holds only the project's state; the reasoning lives here.
The outbound direction was credit-gated (websocket.send awaits its window)
and the inbound direction had no backpressure of any kind. Once the
executor's submit channel filled, WSGIHandler.ws_message discarded each
further message with a log line the client can never see -- the same "a full
channel means skip this frame" mistake the chunk channel's own rule forbids,
in the direction nobody had written a rule for. 2932 of 3000 lost at
4 KB.
The two directions were coupled, which is why the threshold was so low.
The echo app awaits send inside its receive loop, so a client that stops
reading blocks that send on its OUTBOUND window -- which stops the app
calling receive, which stops the executor draining the channel that
INBOUND messages ride. The outbound backpressure produced the inbound loss,
and only one of the two directions was allowed to say "wait".
Both may now. The fix is the outbound design in mirror image, plus the one piece a mirror does not give you:
- A window, granted by the consumer.
WS_IN_WINDOW(64 KB) bounds the unacked datagram bytes the loop may have in flight toward one socket'sreceive()queue. The shim acks CUMULATIVELY as the application actually consumes -- a running total, not a delta, so an ack frame the chunk channel has to drop heals at the next consume instead of costing credit for ever. That is why this is the one frame on the seam that may be dropped without an abort. - Read suspension, which is what makes it real backpressure. A handler
that cannot forward returns False from the new
ws_message_takehook, and the loop takes the slot off the read set. The socket's receive buffer fills, TCP advertises a zero window, and the CLIENT stops sending. Nothing else in the design propagates past the process boundary. - A parked queue, bounded by ONE
recv. Parsing is all-or-nothing: a single 4 KB read can yield many complete messages and the window can run out partway through. The remainder is parked and replayed in order. It can never grow past what onerecv_stagingproduced, because no further bytes are read -- bounded by the receive buffer, not by the client's send rate, which is the property that makes it safe.
take_ws_resumes names the drained slots once per pass and the loop re-arms
their reads. Both new trait methods carry defaults (ws_message_take
forwards to ws_message), so every handler that is not the WSGI one is
untouched.
A pool-held socket gets the same treatment for free: its channel refusal
parks and suspends identically. It has no window, because pool threads send
no consumption acks -- the per-pass retry in take_ws_resumes is what
drains it.
The gate's shape was chosen by measuring the wrong one first, and this
is the part worth remembering. A client that reads CONCURRENTLY loses
nothing on the BROKEN build -- 3000 of 3000 echoed, zero drop lines -- so
the obvious "send a lot and count the echoes" test passes on the defect it
was written for. scripts/ws_inbound_loss_probe.py therefore sends WITHOUT
reading until the socket stops taking bytes (phase A), then reads and sends
together and requires every message (phase B). Phase A stalls on BOTH builds
-- socket buffers fill either way, 380 KB broken against 360 KB fixed -- so
it is a precondition, not the assertion. Phase B is the discriminator:
| build | phase A stall | phase B echoed | lost | drop lines |
|---|---|---|---|---|
| pre-fix | 380 KB / 93 msgs | 68 | 2932 | 2932 |
| fixed | 360 KB / 88 msgs | 3000 | 0 | 0 |
Verified by running the gate against a rebuilt pre-fix binary: exit 1, naming the dropped messages. Holds at 8000 x 4 KB, 3000 x 16 KB and 20 000 x 64 B -- the last being the parked queue's hardest case, many complete messages out of one read.
And it uncovered an older bug, which is the part worth reading. The first Linux CI run of the new gate stalled -- 3 of 3000 echoed, and NO drops, so the backpressure was working and the socket simply never resumed. Two fixes were needed and only the first was mine:
slot_read_armedis an INVARIANT every re-arm site in the loop consults, and suspending without updating it left the flag lying. On kqueue read and write are independent filters so nothing noticed; on epoll they share ONE registration, so the outbox drain'sadd_write_oneshotMODs the read interest away and nothing restores it. Setting the flag is not enough either:_after_send's WebSocket branch re-arms on exactlynot armed, so the socket's own echo would undo the suspension it had just asked for -- and with it the parked queue's bound. HenceWSState.inbound_suspended: a DELIBERATE suspension, distinguishable from an incidental one.- The WebSocket read path took ONE
recvper event and never re-armed -- A13's defect, in the one path nothing had ever sent a large inbound burst to. The body path has carried the fix and the reason for a long time ("a body larger than the staging buffer leaves bytes pending that will never raise another edge on their own"); this path was simply never asked. kqueue's level trigger hides it entirely. On epoll the edge is spent, and the stall needs the client to STOP SENDING -- which is precisely what inbound backpressure is for, so the feature exposed the bug that had been waiting for it. Re-armed only on a FULL staging buffer, so an ordinary small-message socket pays no extra syscall. Row I19.
Verified by building Linux/aarch64 in a container and running the gate there: 3 of 3000 before, 3000 of 3000 after, same tree. The first hypothesis (the invariant alone) was wrong and the container said so -- macOS passed every version of this, including the two broken ones.
One consequence worth stating plainly. A client that sends without ever reading, against an app that echoes, now BLOCKS instead of losing data. That is the correct end of a deadlock every echo server has -- uvicorn included -- and the old behaviour only avoided it by discarding the client's messages.