Why m0serve has two execution modes
What each execution mode is for, what free-threading changes, and where each has a cliff.
One server, two ways of running Python, chosen by the protocol of the application it is given. This page says what each mode is, why one would not do, and where each one has a cliff. Which flags to pass is in Running m0serve; the dated design essay with the measurements behind every claim here is the design record.
The two modes
| WSGI application (Django, Flask) | ASGI application (FastHTML, Starlette, Django ASGI) | |
|---|---|---|
| how the app is called | one synchronous call per request | one long-lived coroutine with receive and send |
| what runs by default | one event loop and a pool of min(cores, 8) handler threads |
one event loop and an asyncio executor |
| where overlap comes from | threads waiting on I/O or inside a GIL-releasing C call; --workers processes for the rest |
the app's own awaits; --workers processes for CPU |
| a slow view | holds its handler thread; the loop keeps serving | blocks the loop unless it awaits, as under uvicorn |
| realtime | --realtime: a view approves a held SSE stream or WebSocket with two headers, m0pub.publish reaches every subscriber |
native: the app streams its own response or takes a websocket scope |
| several apps in one process | --mount, on the pool |
--mount, each ASGI mount on its own executor |
In both modes the Mojo event loop owns the sockets: accepting, parsing, framing, timeouts, backpressure, static files and the health path never enter Python. Only the step that runs the application differs.
Why not one mode
WSGI and ASGI answer the same question, how a request reaches Python code,
in incompatible ways. A synchronous view cannot run on an event loop without
a thread pool underneath it, which is what WSGIMiddleware in Starlette does
and why it inherits every limit of the loop's thread pool. A coroutine cannot
run without an event loop at all. So each protocol gets the runtime it was
designed for, and the server in front of both is the same.
The alternative most servers take is to host one protocol and tell the other
to bring its own process. That is what makes mixing a sync Django app with an
async FastHTML app mean two servers behind a reverse proxy everywhere else,
and one m0serve --mount here.
What the WSGI mode is for
A pool, not a loop per request. A WSGI worker is busy for a request's whole wall-clock time. Running views on the event loop thread would let one slow view stall every keep-alive connection behind it; running them on a pool of handler threads behind the loop keeps a slow view's cost to its own thread. This is the default because it is the difference between a p99 measured in milliseconds and one measured in the length of the slowest view (the slow-view isolation benchmark).
Held connections without a pinned worker. WSGI has no wire for
WebSockets, and an SSE stream under WSGI is an infinite iterable that occupies
its worker for as long as the client stays. m0serve moves the held connection
to the event loop: the view runs to completion, approves the hold with
M0-Hold and M0-Channel, and returns; the loop keeps the socket, delivers
what m0pub.publish sends, and closes it when the client leaves. That is the
headline capability, and it is why WSGI is first-class here rather than a
legacy path. Under gunicorn the same headers are ignored and the view degrades
to a short plain response.
Parallelism. --workers N forks N processes, the answer that always
works under the GIL. Handler threads also give real parallelism, but only for
work that lets go of the lock: a database driver, an image codec, numpy or an
inference engine overlaps across the pool, and pure Python does not (SPEC
E19). Which of the two your slow view is decides whether more handler threads
are worth anything, and it is not always obvious — Core ML's predict holds
the lock, so an embedding app gained nothing from a second thread and lost a
third of its throughput on eight (the measurement).
On free-threaded CPython, --threads N runs N event loops in one process with
one interpreter, each loop with its own pool; the weekly canary proves that
path on 3.14t.
What the ASGI mode is for
The application's own concurrency. An ASGI app expects to overlap
requests wherever it awaits, to stream a response as it produces it, and to
hold a WebSocket as a coroutine. The executor is a persistent asyncio loop per
event loop, fed by the Mojo loop and answering back through it; streaming
responses stream for real, websocket scopes work, and the framework's whole
surface runs unmodified. The Mojo loop still owns framing and flow control:
every stream is credit-gated so a fast producer cannot overrun a slow client
or the channel between the two threads.
What an application can count on, each as uvicorn does it, and each a SPEC row with a gate:
- A response is answered at its final
http.response.body, not when the application returns, so Starlette's background tasks run after the client has its answer, and a client leaving does not cancel them (L22). - A send is judged by the connection it addresses, whichever task makes
it: a disconnect hook reaches the sockets still connected, and a
sendkept for a client that has gone never reaches another client (L20). On a WebSocket that has gone it raisesClientDisconnected, anOSError(Starlette turns it intoWebSocketDisconnect); on a stream it does nothing, as ASGI 2.3 specifies. - A WebSocket hears its client leave as
websocket.disconnectfromreceive(), and is not cancelled for it, so anexcept WebSocketDisconnect:cleanup runs (L21) — also when the server ended the socket itself (a message over its 64 KB outbox), and after the application's ownwebsocket.close, after which it may send nothing more. A handler that never callsreceive()learns at its next send, or at shutdown, when the drain cancels the sockets still running after a second's grace. Background work after a response gets three seconds of the drain in all, and is then cancelled, so lifespan shutdown still runs (SPEC D11). - Once a response is answered,
receive()sayshttp.disconnectat once, and a send answers nothing — so a task the application left behind can never answer another request. - An application error keeps the application's own error response (so
debug=Truepages arrive), the log gets its traceback (L23), and a WebSocket whose application raises is closed with 1011 (L24). scope["path"]keeps%2Fencoded, a deliberate rule of the URI parser so an encoded slash never becomes a path separator; a route parameter that contains one arrives encoded, where uvicorn decodes it (and routes the request elsewhere).
A buffered fallback. --blocking-threads N with an ASGI app selects the
older buffered path, one request at a time to completion on a pool thread. It
exists as an escape hatch and refuses an infinite stream rather than hold a
thread forever. It also answers only when the application returns, so a
response there still waits for its background tasks, and an error the
application answers and then re-raises (Starlette's error page) is replaced
by the server's own 500.
One limit, from the toolchain. The executor is a Python type built
in-process, and Mojo's Python bindings lay that type out for the
GIL build (measured on 1.0.0 and not re-measured since the pin moved to
1.1.0; py-canary is what answers it). On a free-threaded CPython the server refuses an ASGI app with
exit 78 rather than crash, so ASGI under --threads does not exist on this
toolchain (the known issue).
Free-threading
Free-threaded CPython removes the reason most people reached for async in the first place: sync thread-per-request code scales across cores in one process, with shared memory and no async ecosystem to adopt. It does not change the protocol surface. PEP 3333 still has no wire for WebSockets, so the hold contract stays the way a sync app gets realtime, on either kind of interpreter.
The cliffs
- Shared mutable Python objects across threads are the measured cliff of the threaded mode: per-request state must stay thread-local (the record's §5 has the numbers).
- A generator that sleeps holds its pool thread until it yields again,
and the client leaving does not wake it. SSE views whose generators sleep
belong on the executor or as
--realtimeholds, never on pool threads. - A slow view under ASGI blocks the loop unless it awaits. That is ASGI's nature, not m0serve's; the pool is the WSGI answer to the same problem.
- The buffered ASGI path refuses infinite streams by design.
Choosing
With no flags, the protocol chooses correctly for the common case. Reach for
--workers when you have cores to use, --realtime when sync views should
hold connections, --mount when two applications belong in one process, and
--threads only on a free-threaded interpreter with a WSGI app. The flag
table and the reasoning for each are in Running m0serve.