The WebSocket send window — shipped 2026-08-28
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 one path above that an ordinary application reaches, made to work
rather than merely to fail honestly. websocket.send had no window: an
app faster than its client filled the loop's 64 KB per-slot outbox and
every frame past it was dropped — 430,693 of 1,638,400 bytes under a
clean close frame. It waits for drain credit now, exactly as _emit does
for a streaming HTTP response, and the same flood arrives whole:
1,640,193 bytes on the wire, byte for byte (400 × 4 KB payload plus
4-byte headers, the handshake and the close frame).
Almost nothing new was built. The loop was already acking a socket's
drained bytes — a WS slot on an executor lane answers
slot_channel_stream, so the outbox drain's ack_stream fires — and the
shim was discarding those acks, because _exec_credits[slot] is only ever
seeded at HTTP stream start. Seeding it at websocket.accept and awaiting
it in send is the whole change.
One thing had to be got right: credit is charged in encoded frame
bytes, not payload bytes, because encoded frames are what the outbox
holds and what the loop acks back. Charging the payload drifts by the
header on every message — threefold on one-byte sends, which is exactly
the traffic that reaches a 64 KB cap first. _ws_frame_bytes mirrors
encode_ws_frame's unmasked 2/4/10-byte header.
Guarded twice, sabotage-proven both ways. apps/asgi_bare has a
/ws/flood route and ws_probe.py a phase that stalls two seconds and
then asserts the exact frame and byte counts, so smoke-asgi carries it
in CI; ungated it reports "15 of 400 frames (61440 of 1638400 bytes)
arrived". And shim_ownership.py pins the arithmetic with no server at
all: one window of 65536 bytes holds exactly 15 frames of 4100, credit for
eight releases exactly eight more. Two sabotage entries — the await
removed, and the window never seeded — each fail that test.
Two limits it does not lift, both loud rather than silent now: a single
message larger than MAX_PENDING_BYTES is refused by the outbox, whose
cap bounds one frame as well as the queue; and a --realtime hold on a
WSGI lane gets no window, the loop not acking those sockets. Neither is
new, and _ws_spend returns without charging where there is no window
rather than pretending to gate.