m0serve Realtime from a synchronous Python app

The loop inversion — in progress 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 handoff's item 1: run the Mojo loop's pass as a callback inside the executor's run_forever, on one thread, so a request goes parse → app → response with no datagram and no cross-thread wake. At c16 the pump batches about one submit per pass, so every request pays two wakes today; removing them is the whole bet, and the gate is unchanged — ≥1.0x uvicorn --loop asyncio at c16 on stdlib asyncio, both loops measured, RSS 0 KB over 10k requests, stress-asgi N of N.

Landed so far, each a verbatim move with zero behaviour change: run_event_loop is prepare_loopLoopState + a while over _run_pass / _run_shutdown. Established on the way: asyncio's KqueueSelector fires add_reader on a kqueue fd (spiked live); backend.wait(0) is a real non-blocking poll; field-projected ref bindings of one mut struct pass exclusivity as separate mut arguments.

Built and measured, first cut (2026-08-28, M0_INVERTED=1). Correct under every gate: smoke-asgi with 0 KB RSS, fan-out, Django ASGI, FastHTML, stress-asgi 30/30 — on kqueue, and on epoll too, verified in a Linux container before CI (scripts/epoll_inverted_check.sh under colima, linux/aarch64: the smoke with 0 KB RSS, the recycled-slot probe, stress-asgi 30/30 under 8 hogs). Two single-thread traps found and fixed on the way — a producer waiting for the loop to drain the chunk channel was waiting for itself (_place_frame runs a pass instead), and a direct job overtook the slot's disconnect tag on the FIFO submit channel and stamped the new task (notify_disconnect goes direct). Both showed as the recycled-slot probe timing out with a clean log.

The bet was half right. Same session, uvloop executor, c16, two samples of three rounds: inverted 59.1–59.6k rps at 0.87–0.88 cores (p50 263 µs), pump 62.6–63.1k at 0.98 (p50 237 µs), uvicorn asyncio ~57.5k and uvicorn uvloop ~82.4k at ~0.99. The wakes were ~1 µs of CPU each and are gone — that is the −11% of cores — but the pump's two threads were also overlapping Mojo parse/write with Python app work, and at c16 wrk is a closed loop (16 ÷ p50 is the rps), so +27 µs of serialized latency per request is −6% rps. Per core the inversion is +5% (~67.6k vs ~64.5k rps/core); against uvicorn asyncio it is 1.03x on uvloop. On stdlib asyncio — the gate's own row, executor on the system Python 3.13 with no uvloop — inverted ~54.0k at 0.89 cores and pump ~53.4k at 0.99 against uvicorn asyncio ~57.6k: +1% rps at −10% CPU, +12% per core, and BOTH arms at 0.93x uvicorn asyncio, so the gate (≥1.0x at c16 on stdlib asyncio) is met by neither. Artifacts, both arms and both loops: bench/results/inverted-ab/. The default stays the pump. What would change the verdict is not fewer wakes but less serialized work per request — the 2.05 µs parse and the per-pass 1,024-slot outbox sweep were the two named levers — or a workload where CPU, not closed-loop latency, is the bound.

The parse lever, taken 2026-08-29, moved the gate's row for both arms — and cleared it for both. Same session, one binary per parser, the executor on the system Python 3.13 with no uvloop (the gate's own row), c16, medians of three, uvicorn asyncio re-measured beside every arm (bench/results/parse-lever-ab/):

executor old parser new parser
pump, stdlib asyncio 55.7k @0.96 cores — 0.96x uvicorn asyncio (58.1k) 60.1k @0.97 — 1.03x (58.4k)
inverted, stdlib asyncio 54.5k @0.89 — 0.93x (58.7k) 59.7k @0.88 — 1.01x (59.0k), 67.9k/core
pump, uvloop 63.2k @0.97 — 0.77x uvicorn uvloop (81.9k) 69.1k @1.00 — 0.83x (83.4k)
inverted, uvloop 60.2k @0.88 — 0.72x (83.7k) 66.4k @0.86 — 0.79x (84.0k), 77.2k/core

The parser is the same 1.1 µs cheaper under all four, and on a closed-loop client that is +8–9% rps on the pump and +10% on the inversion, on either loop. What the lever did NOT change is the inversion's standing against the pump: on throughput it is within noise on stdlib asyncio (59.7k against 60.1k) and −4% on uvloop (66.4k against 69.1k), and per core it keeps +9% and +12% (77.2k/core on uvloop is 0.92x uvicorn-uvloop's, where its rps is 0.79x). So the ROADMAP gate as written — ≥1.0x uvicorn --loop asyncio at c16 on stdlib asyncio — is now met by the pump on its own, and the inversion's remaining claim is CPU, not rps. Whether that claim is worth making it the default is the 0.15.0 question; the numbers are filed either way. (The outbox sweep, the other named lever, was taken later the same day — "The outbox sweep", below.)

Evaluated the same day, and the answer is no — not for 0.15.0. Two more measurements settled it. At c256 (uvloop executor, pump → inverted → pump back to back on an otherwise idle machine, comparators within 0.5% across all three arms; the -c256- artifacts in parse-lever-ab/) the per-core edge is gone: pump 88.1k @1.02 and 87.3k @1.02 around inverted 85.5k @0.99 — −2.5% rps, +0.6% per core, tails identical. The +12% per core at c16 is the ~0.1 core of cross-thread handoff the pump pays at light load, and its batching amortizes exactly that away where CPU becomes the bound; the edge does not buy capacity. (A first c256 run had put the inversion at 73k in one round with a 23 ms p99; the drift-control rows showed a 13% dent in the comparator during that arm — another session on the machine — and the clean rerun had no such round.) And the shutdown limitation above is a regression the pump does not have. What the inversion honestly is on these numbers: an efficiency mode for low-concurrency, tail-sensitive deployments — −14% CPU and a better p90/p99 at c16, a worse p50, nothing at saturation, one topology — not a throughput default. The bar for ever promoting it: design item 6 with a smoke that pins the in-flight shutdown case, and a saturation workload showing a gain, which no measurement yet does. (The outbox sweep, the other named lever, was taken the same day — the next entry — and is worth +4.6% to the inversion at c16; it does not change this reading.)