m0serve Realtime from a synchronous Python app

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.