m0serve Realtime from a synchronous Python app

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's receive() 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_take hook, 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 one recv_staging produced, 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_armed is 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's add_write_oneshot MODs the read interest away and nothing restores it. Setting the flag is not enough either: _after_send's WebSocket branch re-arms on exactly not armed, so the socket's own echo would undo the suspension it had just asked for -- and with it the parked queue's bound. Hence WSState.inbound_suspended: a DELIBERATE suspension, distinguishable from an incidental one.
  • The WebSocket read path took ONE recv per 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.