m0serve Realtime from a synchronous Python app

WSGI vs ASGI: the design record

The dated essay that led to m0serve's two execution modes, kept as written and moved here from docs/WSGI_VS_ASGI.md on 2026-09-03; that page is now the concise answer. Section numbers are preserved because CLAUDE.md, the README and the performance pages cite them: §5 is the free-threading path, §6 the verdict that August 2026 reversed, §8 the hybrid ASGI gateway, §9 mounts. One correction since: the asyncio executor cannot run on a free-threaded CPython build (the stdlib's PyObject layout; modular/modular#5726), so an ASGI application under --threads is refused on this toolchain.

The obvious roadmap item after a conformance-tested WSGI host is an ASGI host. This document is the case for not building one now. The short form: ASGI answers two independent questions — how does Python code overlap work in one process and how does an application hold a connection open — and this stack has a better answer to each than adopting asyncio. Free-threaded CPython is dissolving the first question; the server's native streaming already answers the second, and the apps/django_realtime demo plus poe py-canary are the evidence for both claims.

1. The split, precisely

WSGI (PEP 3333) is a synchronous call: the server invokes app(environ, start_response) once per request and the application returns a body. ASGI replaces the call with a long-lived coroutine holding receive and send channels. Everything people adopt ASGI for falls out of one of three consequences:

  • Concurrency. A WSGI worker is occupied for a request's full wall-clock time. Under the GIL, threads don't buy CPU parallelism, so the historical scaling answers were processes (memory-expensive) or asyncio (which requires the async ecosystem). ASGI is how Django reaches asyncio.
  • Protocol surface. WSGI has no wire for WebSockets and no way to hold a connection open except by pinning the worker that owns it. SSE under WSGI is an infinite iterable that occupies its worker forever. ASGI's channel model expresses both natively.
  • Django's own split. Async views run natively only under ASGI. Django Channels — the WebSocket/consumer framework — requires ASGI, full stop. Note what is not on this list: merely having async views. asgiref's AsyncToSync runs an async view under WSGI by spinning an event loop per request — it works, and it yields zero concurrency benefit. An async codebase runs on m0-wsgi today; it just doesn't get anything for it.

2. What free-threading changes — and what it doesn't

Status as of August 2026: free-threaded CPython is an officially supported build as of 3.14 (PEP 779, October 2025) — no longer experimental, not yet the default. Single-threaded overhead is 5–10% (down from ~40% in the 3.13 experiment), memory baseline is +15–20%, and roughly half of the most-downloaded packages with native wheels ship free-threaded variants. 3.13t is a dead end: it systematically immortalizes objects (a structural leak), which is why Django's own CI dropped 3.13t and kept 3.14t.

Django is converging on it from the other side. The test suite passes under free-threading once asgiref ≥ 3.12 is installed; 3.14t entered Django's CI matrix on 2026-07-15 (ticket #36983); and Sam Gross — the author of the nogil work — is landing Django-side contention fixes (the Field.creation_counter global, bounded lru_cache hot paths, HttpHeaders set lookups). This repo's uv.lock already resolves the free-threading-capable pairing: Django 6.1 + asgiref 3.12.1.

What the GIL's removal actually changes: the concurrency argument for async evaporates. Sync thread-per-request code scales across cores in one process, with shared memory, no colored functions, and no async ecosystem tax; early community benchmarks have a plain thread pool beating ASGI-plus-threadpool by 3–4x on CPU-bound handlers, because the async machinery is overhead there.

What it does not change: the protocol surface. PEP 3333 still has no wire for WebSockets and still holds a thread hostage per open SSE stream. A thread-per-connection SSE deployment "works" at a few hundred subscribers and then stops working. Channels still requires ASGI. Free-threading makes the worker model question go away eventually; it does nothing for the long-lived connection question.

So the hypothesis "GIL unlock makes ASGI unnecessary" is half right. The other half needs a server that holds connections without spending a Python thread on each — which is exactly what this server is.

3. What this stack already has

The two halves of an answer, in separate packages:

  • A conformance-tested sync WSGI host (packages/m0-wsgi): PEP 3333 validated by wsgiref.validate and a framework-neutral conformance suite (WSGI_CONFORMANCE.md), one request at a time per process, prefork workers, and a bridge whose per-request Python traffic is three zero-argument calls (the leak rule; smoke-django pins RSS growth — measured at 16KB over 10k requests against a 12MB allowance).
  • Native long-lived connections (packages/m0-http): the event loop owns SSE and WebSocket slots directly — subscriber registry, per-slot outboxes, heartbeat timers, disconnect cleanup, and cross-worker fan-out over the BroadcastBus (one datagram socketpair per worker, inherited across the fork). Ten thousand idle subscribers cost the event loop, not ten thousand Python threads.

What this stack is hostile to is a classic ASGI host, for reasons that are design invariants rather than gaps: the handler runs synchronously on the event loop, the process is single-threaded, Mojo never acquires the GIL, and the fork happens before the first Python call (WSGI_CONFORMANCE.md, "A note on beyond Django"). ASGI wants a Python event loop coexisting with the Mojo one. That remains a separate package to be taken on deliberately — if it is ever needed at all.

4. The circumvention: in-process GRIP

The pattern is borrowed from Pushpin's GRIP protocol, the design behind django-eventstream: a synchronous backend instructs a realtime proxy through response headers, and publishes through a control channel. Fastly runs this as a paid service; Centrifugo and uWSGI's offload engine are the same shape. Here the "proxy" is the server the app is already embedded in, so the whole pattern collapses into one process. apps/django_realtime is the working demo; poe smoke-django-realtime and poe smoke-django-realtime-ws pin it in CI, SSE and WebSockets respectively.

Subscribing is an ordinary Django view answering with two headers:

response = HttpResponse(": connected\n\n", content_type="text/event-stream")
response["M0-Hold"] = "stream"
response["M0-Channel"] = channel

The view runs auth, sessions, anything — it is a normal request to Django. take_hold (packages/m0-wsgi/src/hold.mojo) consumes the instruction headers from the returned response, converts it into an SSE hold (the view's body becomes the head of the stream), and the handler subscribes the connection's slot to the channel. Under a server that has never heard of these headers, the same view degrades to a short buffered response — the GRIP property. The headers are M0-prefixed because this is GRIP-shaped, not GRIP-compatible; wire-level GRIP (and with it django-eventstream) is possible future work.

Publishing never enters Mojo at all. The server exports the bus's write fds once, pre-fork, as M0_BUS_WRITE_FDS; m0pub.py (~40 lines, stdlib only) frames the event and os.writes one datagram per worker — including the publisher's own, whose event loop drains it into sse_peer_frame like any peer frame. No PythonObject crosses the bridge, so the leak rule and the RSS guard are untouched. A synchronous Django view can broadcast to subscribers held by every worker in one line:

m0pub.publish("news", "hello", event="message")

smoke-django-realtime's phase 2 is the headline assertion: two SSE streams pinned on two different workers, ONE POST /publish handled by sync Django, and the frame arrives on both — WebSocket-era fan-out from WSGI-era application code.

WebSockets are the same seam with one asymmetry. A view gates them identically:

response = HttpResponse("", content_type="text/plain")
response["M0-Hold"] = "websocket"
response["M0-Channel"] = channel

but its response cannot become the reply. A WebSocket handshake answers 101 Switching Protocols with a Sec-WebSocket-Accept derived from the client's key, and a WSGI response is fully buffered and re-encoded before a byte leaves the process — Django has no way to emit either. So it does not try. It approves the upgrade, having run whatever auth it likes on a request that reached it as a perfectly ordinary GET, and the Mojo handler performs the handshake with websocket_upgrade(req) against the original request. That split is the whole trick by which a synchronous framework gates a protocol it cannot speak.

Inbound frames make the return trip as ordinary requests. ws_message fires on the event loop with a complete message (fragments assembled, pings already answered), ws_message_request gives it the shape of a POST — payload as the body, channel/slot/opcode as M0-headers — and a plain synchronous view handles it. This is Pushpin's WebSocket-over-HTTP, and the view is unremarkable: it reads request.body and may do anything a view may do.

Outbound, both transports share one bus. The datagram carries a complete SSE frame; a WebSocket subscriber needs the payload rather than the framing, so delivery re-encodes per slot — sse_data_payload recovers exactly what a browser's EventSource hands to onmessage, and encode_ws_frame wraps it. One publish, one frame on the wire, and an EventSource client and a WebSocket client on the same channel see byte-identical messages. smoke-django-realtime-ws asserts precisely that, with four held connections across two workers and one POST /publish.

Event ids are numbered, which is what makes Last-Event-ID mean something. Each publish fetch-adds one Int64 on the MAP_SHARED page the server allocates before forking, so ids increase globally across every worker; the number goes both into the datagram's id field and onto the wire as an id: line. The registry's delivery filter (event_id > last_event_ids[slot]) then declines to re-send what a reconnecting client already has, and the SSE hold seeds that value from the request's Last-Event-ID header. Python cannot do the fetch-and-add itself — there is no atomic read-modify-write over a raw address in the stdlib, and a racy one would hand two workers the same id — so m0_shared_fetch_add is exported from m0-core's C ABI and called through ctypes, which never crosses the WSGI bridge and so leaves the leak rule and the RSS guard untouched.

Honest limits, all documented at the source: one channel per connection (SSERegistry stores one filter URL per slot); suppression, not replay — a client resuming at id 12 is not re-sent event 12, but events 13..N are gone unless it was connected, because catching up needs a journal (DatastarStream has one, the raw registry does not); numbering degrades to unnumbered frames when M0_CORE_LIB/M0_SHARED_ID_ADDR are absent, which is what happens under any plain WSGI host; a WebSocket subscriber receives an event's data, not its event: name, since a frame has no field for one; bus frames cap at 64KB; fan-out is best-effort under backpressure, like the bus itself; and the hold pattern removes the connection cost from Python, not the request cost — a slow Django view still holds an interpreter for as long as it runs. What changed on 2026-08-26 is where: --blocking-threads now composes with --realtime, so that view holds a pool thread while the loop keeps every held stream alive through it (a pool thread forwards the hold it takes to the loop's registries as a reserved bus frame). ws_message followed: under a pool it runs on a pool thread too, and under --mount on the thread serving the mount whose view gated the upgrade (the hold frame carries the lane). The sentence above about a slow view stalling "its worker's event loop" is therefore about the loop only when no pool is configured.

5. The free-threading path for m0-wsgi itself

poe py-canary probes whether this stack can run on free-threaded CPython at all: it swaps .venv onto 3.14t built from the same uv.lock, runs the whole WSGI suite — unit tests, PEP 3333 conformance, Django and Flask end-to-end, the RSS guard at a free-threading allowance, the realtime hybrid — and restores the pin in an EXIT trap. scripts/py_canary_probe.mojo reports, from inside the embedded interpreter, what nothing else can: whether the build is free-threaded, whether the GIL is actually off, and whether the bridge's load-bearing mechanics (exec'd shim, persistent bytearray, raw ctypes address crossing) survive.

First run (2026-08-21, macOS/arm64, CPython 3.14.2t): PASSED, every phase. Mojo 1.0's std.python loads libpython3.14t.dylib (via MOJO_PYTHON_LIBRARY; plain PATH resolution also finds it once the venv is swapped); the embedded interpreter reports the GIL genuinely off; the shim mechanics pass; PEP 3333 conformance, forked workers, the Django and Flask suites, and the realtime hybrid all run green under PYTHON_GIL=0. The RSS guard's measured number is the striking one: -3664KB over 10k requests — under free-threading the process shrank while serving (the pinned 3.13 build measures +16KB on the same loop). The zero-argument-call leak discipline holds on a free-threaded build. One sharp edge worth recording: PYTHON_GIL=0 aborts any non-free-threaded CPython at startup — including the mojo driver script itself when the pinned venv is still on PATH — which is why the canary scopes that variable strictly to the swapped environment.

The multi-thread question is also measuredpoe py-thread-probe (scripts/py_thread_probe.mojo) spawns raw pthreads from Mojo, has each attach with PyGILState_Ensure (after the main thread's PyEval_SaveThread — on a GIL build workers would otherwise block forever against the state Py_Initialize left attached), and calls into the interpreter from every thread, checking results. Its mode matrix varies exactly one thing at a time — which layer makes the call, and where the loop's state lives — so an anomaly can be attributed, not guessed at. Measured 2026-08-21/22 on an M4 (4P+6E), CPython 3.14.7t, baselines warmed (the first call pays bytecode specialization; timing it as the baseline made workers look super-linear):

Build Mode — where the loop's state lives Threads Speedup
3.13.7 every variant 4 ~1.0x — works, serialized: the GIL signature
3.14.7t interop — function locals 2 ~2.1x
3.14.7t interop — function locals 4 3.96x
3.14.7t interop — function locals 8 3.64x — E-core dilution past the 4 P-cores
3.14.7t rawfn — PyRun_SimpleString, function locals 4 3.52x
3.14.7t raw — PyRun_SimpleString, __main__ globals 4 0.81x
3.14.7t rawnames — same dict, per-thread distinct keys 4 0.71x
3.14.7t sharedobj — interop closure mutating one shared list 4 0.75x
either naive — no PyGILState_Ensure 4 process dies; the discipline is load-bearing

Three conclusions, each isolated by a pair of rows:

  • Mojo 1.0's interop layer is usable from foreign threads and parallelizes essentially perfectly (3.96x on 4 threads) — "Mojo never acquires the GIL" is the bridge's current design choice, not a toolchain limit. Patch level matters: the identical run on 3.14.2t measured 2.75x, so free-threading contention fixes landing in 3.14.x patches are worth ~1.2x here all by themselves.
  • The anti-scaling mechanism is confirmed to be shared-object contention, located to the object. The same PyRun_SimpleString path scores 3.52x when the loop's state lives in function locals and 0.81x when it lives in __main__'s dict; giving every thread its own keys in that one dict (0.71x) does not help — it is the dict's per-object lock (a PyMutex, taken via the critical-section API), not the keys. A closure hammering one shared list through the interop path (0.75x) shows the same cliff in the shape a shared cache or counter would take. Contended PyMutex acquires park threads and ping-pong cache lines, and cross-thread object traffic drops off biased refcounting's fast path — which is how "serialized" becomes "below serial".
  • Thread-local state parallelizes; hot shared mutable Python objects anti-scale below the serial baseline. Per-request WSGI state is naturally thread-local — the right shape — and the shared-object cliff is precisely why Django's own free-threading contention work (the Field.creation_counter class of fix) matters to real throughput.

A third probe, poe py-thread-probe-stdpy, closes the last toolchain question: the pinned stdlib's Python().cpython() exposes PyEval_SaveThread / PyGILState_Ensure on the dlopen'd handle, so the attach/detach discipline needs no libpython on the link line — it runs under plain mojo run, prints from its pthreads, and uses a parametric def as the start routine, which is exactly the shape a threaded server spawns.

The threaded mode exists: m0serve --threads N / M0_THREADS=N. Stage A of the design is loop-per-thread — Granian's free-threaded shape, "workers are threads instead of separated processes" — and it is the shape that needed no change to the event loop: every per-slot structure was already a local of run_event_loop, so N threads calling it get N disjoint loops, and each thread's own handler means its own WSGIApp, bridge and shim namespace, which made the bridge's per-process singletons per-thread without touching the bridge. m0_wsgi.threaded is the choreography the probes specified: main initializes and imports before spawning, then detaches; every thread attaches once, serves, and releases; DetachingBackend wraps the loop's one blocking wait (the rule above, applied); a GIL-enabled interpreter refuses to start with exit 78. Measured on 3.14.7t: four loops serve the conformance routes, a request arriving while one thread sleeps in a view is answered by another in under a millisecond, /reentrant's call back into the server is answered by a second thread instead of a second worker, and SIGTERM drains all four cleanly. smoke-threads pins the guard on every CI runner and the mode on the weekly canary (phase D).

What Stage A does not buy, stated plainly: per-request balancing. A keep-alive connection stays pinned to the loop that accepted it, exactly as under prefork, so the keep-alive p99 tail in WSGI_PERFORMANCE.md is unchanged by it — and a slow view stops every connection its loop holds, which the mixed-workload row in that document measures at ~120x on fast-request p99, identically under --threads and --workers.

Stage B is that fix and it has shipped: --blocking-threads N, an acceptor loop feeding a pool of handler threads, which is Granian's inner --blocking-threads shape. It is orthogonal to Stage A rather than a successor — one pool per loop, so it composes with prefork and with threads alike — and it is the one piece of this that pays off on a GIL-enabled interpreter too, because a view waiting on a database or a socket releases the GIL. ROADMAP.md carries the design and the three places the implementation departed from it. Shared Python state above the bridge (Django's own caches — the contention Sam Gross is patching upstream) is the scaling risk the free-threaded rows measure. The prize Stage A already pays out: one process, shared memory, no per-worker RSS duplication, no bus needed for in-process fan-out, and the entire class of fork-after-init hazards (the macOS _scproxy/objc abort, exit_worker, fork-before-first-Python-call) gone for anyone who opts in.

6. Verdict

Do not build an ASGI host now.

  • For concurrency: prefork workers cover today's parallelism; free-threaded threads are the credible successor, and the canary keeps that path measured as Mojo, CPython, and Django all move. An asyncio bridge would be the most complex of the three options and obsolete-on-arrival if threads land.
  • For realtime: the hold/publish pattern gives sync Django the SSE and WebSocket surface people adopt ASGI for, on infrastructure this repo already maintains, with no Python event loop. WebSocket holds landed as an increment on the same seam — one new mode value and a synthetic request — which is the evidence for the claim, not a new architecture.
  • Revisit only if a workload genuinely requires Django Channels' consumer model or an async-native framework (FastAPI/Starlette) — that is an "ASGI host as a separate package" decision, to be taken with the canary's findings in hand.

(That condition fired in August 2026 — see §8 for the revisit and what was actually built.)

7. Where this fits the larger aims

Against the project's Django-server aims (hybrid gateway, static files, DX, benchmarks), this work slots in as follows: the "WSGI thread pool instead of Gunicorn-style forks" aim has landed in both of its halves — --threads N for the loops (gated on exactly what py-canary measures, since a native thread pool running Python is real parallelism only without the GIL, so the canary is that half's standing go/no-go probe) and --blocking-threads N for the handlers, which needs no such gate because the parallelism it buys is waiting, not computing. The "ASGI bridge" aim exists mostly to serve realtime and async codebases; the hold/publish pattern covers the realtime half without it, narrowing the bridge to genuinely-async codebases and making it deferrable. The static-files aim is covered: m0_http.StaticFiles (ETags, ranges, traversal-hardened) fronts the Django rows — apps/django_realtime serves its assets from the Mojo layer with a Cache-Control policy, and those requests never enter Python; only the zero-copy sendfile optimization remains recorded (it needs event-loop support for fd-backed bodies). The uvicorn-style CLI exists: m0serve MODULE[:ATTR] --host --port --workers --app-dir --static is one built binary serving every WSGI row. Auto-detection of ASGI vs WSGI applications, PyPI-wheel distribution, hot reload, and the Granian benchmark suite are follow-ups recorded in ROADMAP.md's orbit — none of them depend on the ASGI decision made here.

8. The revisit (2026-08): the hybrid gateway

§6's revisit condition fired: an async-native framework became a real workload — FastHTML (Starlette-based ASGI) — and with it the entry point's recorded follow-ups (auto-detection, zero-config defaults) stopped being deferrable. The decision taken is a phased hybrid gateway inside m0-wsgi, not the separate asyncio host §3 warned about; each phase keeps every invariant this document defends (fork before first Python, no per-request PythonObject traffic, Mojo never acquiring the GIL, thread-local Python state).

Phase 1 — shipped: detection + the buffered ASGI bridge. m0serve detects WSGI vs ASGI from the application object at load (coroutine-function duck typing, uvicorn/asgiref's rule; --protocol overrides), and a bare MODULE also discovers MODULE.asgi:application, MODULE.wsgi:application, MODULE:app, MODULE.main:app by convention. An ASGI app runs on a persistent per-bridge asyncio loop, one request at a time to completion (run_until_complete), with the scope built in the shim from the same C-API environ and send() events buffered into the same (status, headers, body) tuple the WSGI path returns — zero new Mojo code on the per-request path, and smoke-asgi's RSS guard measured 356 KB over 10k requests on day one. Lifespan runs at startup with uvicorn's "auto" semantics (an app that errors on the scope doesn't speak it; an explicit startup.failed refuses to serve). Two honest limits, both enforced loudly rather than silently: a streaming response (more_body=True) that has not finished in 10 s is answered with an explanatory 500 naming this section (an infinite SSE/EventStream cannot ride a buffered bridge), and a second receive() waits — uvicorn parity — so Starlette's streaming responses meet that watchdog instead of returning accidentally-truncated 200s. Zero-config also landed here: when no topology flag or M0_* topology variable is given at all, m0serve starts --blocking-threads min(cores, 8) by default (either protocol), so one slow view no longer stalls the out-of-box server; --realtime keeps the single-loop shape, and any explicit topology value — including M0_BLOCKING_THREADS=0 — wins.

Phase 2 — shipped: the per-loop asyncio executor. Real await-concurrency (uvicorn's shape) without a coexisting-loop architecture: one Python thread per Mojo event loop runs a persistent asyncio loop, fed through the unchanged OffloadPool — the loop parks the request and submits the slot exactly as --blocking-threads does, the executor's loop.add_reader on the submit fd turns each slot into a task, and task completion answers through put_response/complete, which any producer holding the pool address may drive (m0_wsgi.asgi_executor; the pump batch-drains events so one run_until_complete enter/exit amortizes over everything ready). Every Python object stays touched by exactly one thread (the §5 cliff is avoided structurally), and on a GIL build the selector inside run_until_complete releases the GIL, so the detached Mojo loop and the executor interleave. ASGI no longer defaults to a pool — the executor is its concurrency (use_asgi_executor; an explicit --blocking-threads N>0 keeps the buffered pool as the escape hatch) — and the banner says asgi-loop. Measured on day one: eight concurrent 1.5 s awaits complete in 1.51 s on one loop with zero threads (the buffered bridge takes 12 s), the RSS guard stays flat — 20 KB to ~1.7 MB over 10k requests across runs, allocator/arena noise (uvloop's included) rather than growth, against the 12 MB limit (the executor path crosses method/path/query/headers directly through the C API — no environ, no CGI names, no Python-side re-transform) — and exactly one lifespan runs per event loop (the loop's fallback handler is built with lifespan=False). The bench-asgi gate against uvicorn: the mixed slow/fast tail passes (fast p99 2.87 ms vs 3.27 ms) and hello-world throughput stands at 0.88–0.94x across runs — the located remainder and its fix paths are recorded in WSGI_PERFORMANCE.md §"The ASGI executor vs uvicorn". The executor opportunistically uses uvloop for its own loop where installed, stdlib asyncio otherwise.

Phase 3a — shipped: streaming ASGI responses. An http.response.body sequence with more_body=True now actually streams — FastHTML's EventStream, Starlette's StreamingResponse, Datastar patch streams — by reusing the §4 transport rather than building one. The executor publishes response chunks as bus-shaped datagrams on a private per-loop channel (OffloadPool.enable_stream_channel; the chunk pair's read end is the loop's bus_read_fd, with a realtime server's own bus channel moved to peer_bus_fd beside it — the two coexist since --realtime learned to share a process with an ASGI mount), delivered through the existing drain_bus_channelsse_peer_frame path into the loop-owned SSERegistry under reserved channel names that open with a control byte no HTTP header value can carry. The mechanics that make it correct, each pinned by smoke-asgi:

  • Order is a FIFO property, not a hope. A stream's begin frame is sent on the chunk channel before its head rides the completion channel, so the handler is subscribed before the loop ever drains the slot as a stream — and every frame of a stream sits between its begin and end on one FIFO channel, which is what makes a recycled slot safe: chunks that outlive their connection arrive unsubscribed and are dropped, never injected into the next request.
  • Backpressure is credit, not drops. A second private pair carries drain acks loop→executor — (slot, bytes) after each fully flushed buffer — and the shim's send() awaits credit (64 KB window, 32 KB chunk split) before emitting. The registry's 64 KB drop threshold is therefore never reached, and a 100 MB stream behind a slow reader holds server RSS growth to ~2 MB.
  • End of stream is a terminator on HTTP/1.1, a close otherwise. The head goes out without content-length; on HTTP/1.1 it carries Transfer-Encoding: chunked instead, each drain is framed size CRLF payload CRLF, and end-of-stream writes 0 CRLF CRLF and returns the connection to keep-alive. Everything else — an HTTP/1.0 client, a HEAD, a --realtime SSE stream — stays close-delimited, which is what the server did for every stream before. Either way the handler unsubscribes after handing out the final bytes and the loop reads sse_is_streaming (the hook nothing had ever called) to know it happened; only what it does next differs. Two rules the framing introduced: the drain reads sse_is_streaming once per pass, since the drain itself is what flips it and asking twice can straddle the transition; and drain acks count payload bytes, not wire bytes, or a window replenished by the framing overhead grows without bound. A disconnect tag on the submit channel resolves the app's receive() into http.disconnect and cancels its task, uvicorn's contract.
  • No comment heartbeats on ASGI streams: an SSE event may span two chunks, and a : heartbeat between them corrupts the frame (the smoke splits a Datastar event mid-word under a 300 ms cadence and asserts byte-exactness). Dead clients are found by send failure and read-EOF. The buffered escape hatch (--blocking-threads N with ASGI) keeps its 10 s watchdog refusal.

Phase 3b — shipped: websocket scopes. The same seam, and the same correctness arguments. The executor probes each parked request with websocket_upgrade (the loop's own validator); a handshake gets a websocket scope, and the ready 101 is held until the application's websocket.accept — the approve/perform split M0-Hold uses, because the accept value comes from the original request's key. A begin frame anchors the FIFO before the 101 completes (exactly as a stream's head), outbound websocket.send frames are RFC 6455-encoded executor-side and ride the chunk channel into the loop handler's sockets registry, and websocket.close queues the close frame plus the end marker so the loop closes after both land. Inbound messages the loop's parser assembled are forwarded by ws_message as tagged submit-channel datagrams into per-slot queues behind receive() (bounded by max_message_size, under the channel's frame cap); 3a's disconnect tag doubles as websocket.disconnect and cancels the task. An app that returns without accepting — or that raised first — resolves its held 101 as a 403, so no slot leaks. smoke-asgi drives a raw RFC 6455 probe (verified accept, both echo directions, close(1000) through to the FIN, then an abrupt vanish after the 101); smoke-fasthtml proves app.ws end to end. FastHTML's full surface — pages, SSE EventStream, WebSockets — now runs on m0serve with zero configuration.

The M0-Hold/GRIP path is untouched by all three phases and remains the recommended realtime surface for synchronous WSGI codebases.

Phase 3c (2026-08-27) — streamed WSGI bodies, through the same seam. A generator the application did not size — StreamingHttpResponse, a Flask Response over a generator — used to be joined whole by the shim: the buffered-bridge limit applied to WSGI, and the one REAL_APP_VALIDATION.md found illegible on textshelf (a never-ending SSE generator that never answered and held its thread until shutdown). A --blocking-threads pool thread is now a second producer on the executor's chunk channel: the same begin-before-head order, the same s/e frames into the same loop-owned registry, the same loop-side chunked framing and end-of-stream — with stop-and-wait credit on a per-thread ack pair instead of the executor's 64 KB window, and a non-blocking poll of that pair before every piece so the disconnect the loop sends there is noticed by a stream of small events. What buffers is decided by the shim's rules in order — an app-supplied Content-Length first (every Flask page, every Django page behind CommonMiddleware, FileResponse), then list bodies, Django's HttpResponse, HEAD, bodiless statuses and M0-Hold — so no framework page changes on the wire, and the loop thread never streams: a server with no pool keeps joining.

Two things this forced on the shared seam, both for the executor too. Every stream frame now carries a per-stream generation in the bus frame's id: a slot freed by one producer and re-subscribed by another has no FIFO between the two writers, so "queue only if subscribed" was not enough once there were two. And a stream that raises after its head aborts through the completion channel and the loop closes WITHOUT the chunked terminator — a truncated body, which is the truth; the executor used to end such a body cleanly. The hold stays the zero-thread realtime surface; a streamed generator holds a pool thread for as long as a client reads it, gunicorn's shape, and one asleep between events is still the bounded straggler at shutdown. Recorded follow-ups: an iterator that carries its own Content-Length still buffers rather than streaming with its declared length (both protocols), and a framed stream ignores the request's Connection: close at its end (both protocols, pre-existing). smoke-wsgi-stream pins all of it.

9. Mounts (2026-08): several applications, one process

The gateway answers "which protocol is this app?" The premise that started it was messier: a codebase that is partly sync and partly async, where the developer should not have to choose. Detection alone does not settle that — it tells you which single server to run.

m0serve --mount PREFIX=SPEC hosts several applications in one process, routed by longest prefix before either sees the request. Each mount detects its own protocol (discovery included, so --mount /=djangoproj finds djangoproj.wsgi exactly as a positional spec would) and gets its own bridge — which costs nothing to arrange, because PyBridge already execs the shim into a fresh namespace dict per instance, so N apps are N isolated shim states rather than N collisions.

The prefix is the whole correctness story, and the two protocols disagree about it. WSGI wants SCRIPT_NAME = prefix with PATH_INFO trimmed to the remainder; ASGI wants root_path = prefix with path left whole — Django's ASGIHandler strips the prefix itself and hands request.path the untrimmed value. Get it backwards and every direct request still works while every generated URL is wrong, which is invisible until someone clicks something. PyBridge.set_base is therefore the one place either protocol learns the prefix, and smoke-hybrid compares Django's reverse(), Flask's url_for() and both frameworks' request.path byte for byte (verified load-bearing: with the PATH_INFO trim disabled, the Flask mount stops routing at all).

A path no mount claims is a 404 answered in Mojo, never entering Python. Prefixes match on segment boundaries, so /app serves /app and /app/x but never /application, and the root mount is the empty prefix — which needs no special case, since every target starts with / and any deeper mount outranks it.

Stage 2: each mount in its own native execution mode. The refusal of mixed WSGI/ASGI mounts is gone, and with it the reason mounts were a convenience rather than a capability. A submit lane — one SOCK_DGRAM pair per mount — replaces the single submit channel, so the loop's pool.submit(slot, path) hands a job to the worker that can actually run it: the asyncio executor for the ASGI mount, handler-pool threads for the sync ones, dealt round-robin across their lanes. One ProvisionPool per loop stays (a slot indexes that loop's provisions, and a pool shared between loops would answer the wrong connection); only the submit side became per-mount. match_path_prefix in offload.mojo is the single implementation of the matching rule, so the lane a job takes and the application the handler picks cannot disagree.

Each worker builds only its own mount's application (only_mount) — building the rest would run one lifespan per mount per thread for applications it can never be handed.

Measured, and pinned by smoke-hybrid: with four blocking 2-second Django views holding every pool thread, the FastHTML mount answers at p50 1.3 ms, p99 2.8 ms. Sharing one execution mode puts those in the seconds — which is the same shape as the mixed-workload run that justified --blocking-threads in the first place (§WSGI_PERFORMANCE.md).

Several ASGI mounts (2026-08). The one-ASGI-mount limit is gone: each ASGI mount gets its own executor, and the ack routing that blocked a second one is built. The parts, each carrying one invariant:

  • Executors share ONE chunk channel. Its datagrams were always slot-addressed, and one SOCK_DGRAM queue is globally FIFO across writers — an old stream's frames all precede its end marker, which precedes the close, the recycle, and therefore the new stream's begin — so the recycled-slot safety argument survives two writers untouched.
  • Each executor gets its own drain-ack pair (enable_stream_ack), and the loop routes every ack by slot_lane, recorded at submit. Credit belongs to the executor that owns the slot: an ack sent to the other lane is not an error but a stream stalled forever, because the owner's send() awaits a window only its own ack fd replenishes. Pinned by streaming 256 KB — four credit windows — from both executors concurrently.
  • The reserved channel name carries the lane (\x01<kind>/<slot>/<lane>; the unmounted wire format is unchanged). It is stored as the slot's subscription filter url, so a disconnect tag or an inbound WS message routes to the owning executor's submit fd by parsing the slot's own record — no side table to drift.
  • Shutdown sends one pill per executor, each on its own lane — the stage-2 SIGTERM lesson applied to N.

What is still refused, and why it is refused rather than guessed:

  • --realtime on a server with no WSGI mount at all. A hold is taken by a sync view answering with M0-Hold; a server whose every mount is ASGI has nothing that could take one, so the flag is asking for machinery nothing can use, and it is refused at startup rather than armed and idle.

Two refusals that stood here until 0.12.0 have shipped, and the ROADMAP has the record: --mount with --realtime (the loop tells a held stream from an executor's per slot by lane, and an inbound frame is delivered to the mount whose view gated the upgrade), and per-mount modes under --threads (_serve_one mirrors _serve_offloaded per loop, so N loops of per-mount modes is N times the prefork shape).

Why this is the hybrid advantage rather than a convenience. uvicorn hosts one callable; daphne hosts one; Granian hosts one. Mixing otherwise means two processes behind a reverse proxy, or composing in Python (Starlette Mount + WSGIMiddleware, which drops the sync app onto the event loop's threadpool and inherits every limit that implies). A sync application and an async one sharing one listener, one set of workers and one graceful shutdown while each keeps its own concurrency is the thing no other server in this space does, and it is what pays for the complexity §8 spent.

Sources