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_loop → LoopState + 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, removed from the tree on 2026-09-28 and last in commit 6daa4fb, 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.)

Design item 6, built 2026-09-08: the drain is stepped, not blocking

The shutdown limitation above is gone, and it was one line of shape. The inverted pass_ called _run_shutdown — the whole graceful drain, its 5 s budget and its 100 ms waits — from inside an asyncio callback. The application's in-flight request tasks live on that same loop, so the callback that was waiting for them to finish was the reason they could not run: active_count never fell, and every in-flight request was answered when the budget ran out rather than when it was done. The pump does not have the bug because its drain runs on the Mojo thread while asyncio keeps running on another.

_run_shutdown is now three functions, in loop/shutdown.mojo — _shutdown_begin (leave accept sharing, close the listener, farewell the streams, stop watching the shutdown pipe, stamp the deadline), _shutdown_drain_step (ONE pass, with the blocking wait as a parameter, returning True when the drain is over) and _shutdown_finish (the late farewell, the last submit flush, the accept-sharing record). The blocking composition is unchanged and is what every other topology runs:

var drain_start = _shutdown_begin(handler, backend, st)
while not _shutdown_drain_step(handler, backend, st, drain_start, 100):
    pass
_shutdown_finish(handler, backend, st)

Inverted, the shim steps it instead (_drain_and_stop): drain_step with a wait of 0, await asyncio.sleep(0.001) between passes so the loop runs everything else it has ready, then the post-pill gather, then drain_finish, then stop(). A millisecond rather than sleep(0), which would spin a core for the length of the drain; the drain's own granularity was 100 ms before this, so 1 ms costs nothing and is 100x finer.

Measured with /slow?ms=1500, SIGTERM 300 ms in, on both backends:

kqueue epoll
pump (the reference) 1.50 s 1.51 s
inverted, before 5.36 s —
inverted, after 1.50 s 1.50 s

Lifespan shutdown still runs in every arm, and the process exits within about 10 ms of answering.

The smoke the bar asked for is smoke-asgi's, and it is deliberately not the phase next to it. The existing outlive-the-drain phase uses /slow?ms=5800 and /slow?ms=6800 — requests that exceed the 5 s budget on purpose — so a correct drain and a frozen one both end at the deadline there and it cannot see this defect at all. The new phase sends a 1.5 s request and asserts the answer arrives inside 3 s: a bound loose enough that a slow shared runner will not fail it, and tight enough to separate "finished" from "gave up". smoke-asgi already runs in both loop modes on every pull request, so the inverted arm gates it. Proven by sabotage: with pass_ reverted to the blocking _run_shutdown, the inverted arm fails with a 1.5 s request was answered 5.39s after it was sent.

That is the first half of the promotion bar. The second half — a saturation workload showing a gain — is met too, by measurements that postdate the verdict above rather than by anything the inversion did: see bench/results/inversion-2026-09/ for the Mac artifacts (+16 % per core at 256 connections) and the core-constrained sweep, where the inversion serves 1.14x the pump and 1.42x uvicorn+uvloop on a single CPU. What changed is the pump: the detached loop bought its throughput by spreading over 1.6–1.8 cores, so its per-core efficiency fell as its rps rose. This note's own reading — "or a workload where CPU, not closed-loop latency, is the bound" — is what that turned out to be.