m0serve Realtime from a synchronous Python app

Hardening the streaming seam — shipped 2026-08-27

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.

0.14.0 shipped a fix for a hang that had been live all day: the shim keyed a connection slot's executor state — credit window, event, disconnect mark — by SLOT, while the loop recycles a slot the instant it closes a connection, so a client that closed one stream and opened another on the same slot could leave the loop holding a subscription with no producer. The client saw a 30 s stall against a clean server log. It reproduced 8 of 11 smoke-asgi runs under twelve CPU hogs and vanished under every instrumentation that added a timer.

What that left behind was a gap, not a bug: the fix was verified by an ad-hoc reproducer in a scratchpad, so nothing in CI or poe would catch a re-introduction — which breaks this repo's own rule that every guard is sabotage-verified. And it was one instance of a shape, not a one-off: the same seam had five more paths where a failure was discarded rather than reported.

The guards. Two, because the failure has two natures.

poe test-shim is the deterministic half, and IS in CI. The shim is a Python program inside a Mojo string, so scripts/shim_ownership.py extracts SHIM_SOURCE, execs it, hands it a real asyncio loop and a socketpair per channel, and drives it exactly as the Mojo loop does — 8-byte job datagrams, 9-byte disconnect tags, 8-byte drain acks — with a stand-in _port that records every event and spawns on ('job', slot), which is what ExecutorPort._dispatch does. No server, no Mojo, no interpreter embedding, no threads. Six tests, one per rule; on the shim reverted to its pre-fix shape, four of the six fail. --sabotage (what the poe task runs) reverts each rule in the extracted source in turn and insists the suite fails for every one — the sabotage rule made permanent instead of remembered, and a patch that no longer applies is itself a failure, because that means a guarded line was renamed or deleted.

poe stress-asgi is the timing half, and is deliberately NOT in CI: chunked_keepalive.py N times under CPU hogs, whose HTTP/1.0 probe closes after its head so the keep-alive stream behind it lands on the same slot. On the reverted build it failed on round 5 of 15 under 20 hogs, with a silent server log; on main, 45 of 45 across three runs. Shared runners are already loaded and cannot reproduce this reliably, which is exactly why CI passed with the bug live — so it is a pre-release check (RELEASING.md), not a gate.

The other five paths, all the same shape. A frame the streaming seam could not place was discarded at five of six sites. Each is now terminal and named, and each was measured both ways by forcing the failure:

path before after
b stream begin dropped a clean, empty 200; the log names only a downstream KeyError 500 + close in 11 ms, one named line, the app's task cancelled through its own disconnect tag
e stream end dropped the client hangs to its own timeout (12 s, curl exit 28); log silent bytes delivered, then a close with no terminator (exit 18) in 13 ms, one named line
s chunk refused by the outbox a 12 s hang, log silent abort — truncation the client can see — one named line
w WebSocket frame refused 430,693 of 1,638,400 bytes delivered under a clean close frame; log silent 61,689 bytes then an abrupt close (1006), one named line
a stale drain ack credits a recycled slot's new stream, over-committing the one shared chunk channel clamped to the window

The w case is the reachable one and was reproduced with an ordinary client: an ASGI app sending 400 × 4 KB with no pause, and a peer that completes the handshake and then stalls two seconds. websocket.send was not credit-gated at all — unlike a response chunk, which is gated twice — so the outbox filled. That gap is now closed (see below), which turns the loud failure back into a working one; the tear-down stays as the last resort. The s case is unreachable by construction today (a stream may have at most one 64 KB window of un-acked bytes outstanding, bytes are acked only once they have left the outbox, and the window equals MAX_PENDING_BYTES); it is guarded because that invariant is implicit and one constant away from breaking, and because being wrong about it silently costs a stream that stalls for ever.

A WebSocket could not be aborted at all, which the measurements found rather than the reading: the loop's abort path gates on slot_sse, and a held 101 never sets it — websocket_upgrade builds an ordinary response — so abort_stream on a socket was a silent no-op. Verified by isolating it: with an outbound frame forced to fail, the socket closes immediately when the abort knows about sockets and is still open eight seconds later when it does not. Two lines in event_loop.mojo fix it — the gate reads slot_sse or slot_ws, and a 101 records its generation in OffloadLoopState.stream_gen AFTER the non-stream branch's clear_stream, which was wiping it (the first attempt set it in the upgrade branch and the abort was still dropped, on the generation check instead). Recorded in NOTICE. The loop-side refusal (queue_frame) does not use it: a socket's outbox is unframed, so asgi_done flushes what is queued and the loop's ended and not framed branch closes — which is better, because those bytes are real. And the tear-down has to be claimed once per stream: the producer does not learn its connection is gone until the loop closes it, so one flooding socket announced itself 336 times before stream_lost made it once.

Generation-tagged acks: considered, not built. The real fix for a stale ack is to carry the stream's generation in the ack datagram, as every chunk frame already does. The clamp is exact for the damage that matters — credit + in flight == the window is the invariant, and min() keeps it true — and the residual is a transient over-credit of the global budget that cancels itself on the next real ack (the refund is capped by the slot's own in-flight count). Widening the ack wire format touches ack_stream, the loop's owed-credit retry, the pool thread's stop-and-wait poll and the shim's reader; it is worth doing when one of those changes for another reason, not on its own.