Running m0serve
Flags, execution modes, what to put in front of it, shutdown and exit codes.
How to start the server for the application you have, what each mode is for, and what to put around it in production. The Quickstart is the tutorial; this is the reference you come back to.
Install and start
Install the wheel into the same virtual environment as your application, the way you would gunicorn or uvicorn. It has no Python dependencies and fetches nothing at install time.
pip install m0serve
m0serve myproject.wsgi:application # an explicit callable
m0serve myproject # or let discovery find it
The name is spelled with a zero, m0serve, like the packages underneath it
(m0-core, m0-http, m0-wsgi); moserve with a letter is nothing.
MODULE[:ATTR] names the callable; ATTR defaults to application. A bare
MODULE tries MODULE.asgi:application, MODULE.wsgi:application,
MODULE:app and MODULE.main:app, in that order. --app-dir DIR is
prepended to sys.path and defaults to the current directory.
The protocol is detected from the object: a coroutine-function callable is
served as ASGI, anything else as WSGI. --protocol wsgi|asgi overrides.
m0serve carries no interpreter. It loads the libpython of the python3
on PATH, so run it where that is the interpreter your application is
installed into, an activated virtualenv being the usual way;
MOJO_PYTHON_LIBRARY names the shared library outright. The m0serve
a wheel installs names its own interpreter's library itself, and exits 78
naming MOJO_PYTHON_LIBRARY when that interpreter has none.
The ready signal is one line per worker, printed after the application has imported and, for ASGI, after its lifespan startup has completed:
🔥 m0serve: myproject.wsgi:application on http://0.0.0.0:8000 (protocol=wsgi workers=1) blocking-threads=8 (auto)
An application that fails to import, or whose lifespan startup fails (it
sends lifespan.startup.failed, as Starlette and FastAPI do when a startup
handler raises), prints the error and exits 1 before that line; uvicorn
exits 3 for the same failure. For orchestration, use --health-path /health (answered in the
server, before Python) or a TCP check, not the banner.
Before its banner each worker prints [worker N] pid=P armed for a graceful stop, the Mojo host's line: a SIGTERM drains it from then on, and
kills it before then. Between the two, while an ASGI application's lifespan
startup runs, SIGTERM or SIGINT ends the worker with exit 0 and nothing
served; under M0_INVERTED=1 a stop waits for the startup to finish. A script that stops the server soon after starting it
waits for that line from every worker, since one worker answering says
nothing of another still importing the application.
Which mode
With no topology flag or M0_* topology variable, the protocol chooses:
| your application | what runs by default | why |
|---|---|---|
| WSGI (Django, Flask) | one event loop, a pool of min(cores, 8) handler threads |
a slow view holds its thread, not the connections behind it |
| ASGI (FastHTML, Starlette, Django ASGI) | one event loop, an asyncio executor | requests overlap wherever the app awaits, uvicorn's shape |
Everything else is opt-in:
| flag | use it when | notes |
|---|---|---|
--workers N |
you want N processes on a multi-core host | prefork; a supervisor respawns crashes and drains on SIGTERM |
--spawn-workers |
a worker uses Core ML, Objective-C, MAX's parallel runtime (a Mojo mount that calls parallelize: --workers N and --reload refuse it otherwise, exit 2) or anything else a forked child cannot |
each worker execs the binary afresh after the fork; same supervisor, one extra process start per worker |
--blocking-threads N |
you want more or fewer handler threads per loop | more threads overlap only work that releases the GIL — a database driver, a codec, numpy — never pure Python (which yours is); 0 turns the pool off; for ASGI, N>0 selects the buffered path instead of the executor |
--realtime |
sync views hold SSE streams or WebSockets with M0-Hold |
WSGI only; the Quickstart is the contract. Keeps the default pool: a view holds its stream from a pool thread and the loop keeps every held connection, so one slow view stalls nothing. Through 1.4.0 the flag turned the pool off, so every view ran on the loop; --blocking-threads 0 is that shape by name |
--mount PREFIX=SPEC |
several applications in one process | repeatable; each mount detects its own protocol and runs in its own mode, longest prefix wins |
--mount PREFIX=hold |
a stream held against a grant the application signed | needs --realtime and M0_GRANT_KEY; the application renders m0serve.grant.stream_url(PREFIX, channel, session=...) into an EventSource and the mount holds on a Mojo pool thread, never asking Python; M0_GRANT_KEY_PREV rotates the key, M0_GRANT_COOKIE names the session cookie (sessionid); the note |
--threads N |
free-threaded CPython (3.14t or newer, GIL off), N loops in one process | WSGI only on this toolchain: an ASGI app is refused with exit 78 (why) |
--reload [--reload-dir DIR] |
development | re-forks workers when a watched .py changes |
M0_INVERTED=1 |
an ASGI app on one usable CPU | environment variable, not a flag: the Mojo event loop runs inside the asyncio loop on one thread instead of beside it on two. Unmounted, pool-free ASGI without --realtime only. Measured: 1.14x the default at saturation on one CPU, and slower than it from two cores up. At 16 connections it is the most work per core in every environment measured, and on Linux the most requests too (the record) |
The modes compose the way you would hope: --workers multiplies whatever
each worker runs, --realtime sits beside a pool or a mount, and a mounted
server mixes a WSGI pool with an ASGI executor in one process. The design
behind the split is Why two execution modes.
A mount's prefix reaches its application the way its protocol expects:
SCRIPT_NAME, with PATH_INFO trimmed, for WSGI, and root_path beside
the whole path for ASGI, so reverse() and url_for() build links that
work under it. A path no mount claims is answered 404 by the server,
never entering Python. Each ASGI mount gets an executor of its own, and the
WSGI mounts share the pool.
--doctor reports which loop an ASGI deployment resolved to as
topology.loop — pump (the default, two threads), inverted, or n/a
where no executor serves the application. It reported mode: single for
both shapes until 2026-09-08, so the only way to tell them apart was the
startup banner.
The realtime contract
Under --realtime, a sync view approves a held connection by answering with
M0-Hold: stream (SSE) or M0-Hold: websocket and M0-Channel: NAME; the
server holds it from there. from m0serve import m0pub; m0pub.publish(NAME, data) from any view, command or cron job reaches every subscriber on every
worker. Inbound WebSocket messages arrive at the application as a POST to
/ws/message with M0-Channel, M0-Slot and M0-Opcode headers; that view
must be CSRF-exempt. Channel names beginning with a control byte are
reserved and refused. The Quickstart builds all of it.
A hold replays what the journal holds, and says when it cannot. Event
ids are numbered from one counter across every worker, and each loop
journals the last --replay-frames published frames (M0_REPLAY_FRAMES,
default 64; 0 keeps none; at most 4096, since a loop holds up to that
many frames of up to 64 KB each and m0serve refuses more with exit 78,
as does --doctor). A client that reconnects with Last-Event-ID
is not re-sent what it already has, and is sent every frame of its channel
published while it was away — a phone asleep, a proxy that dropped the
stream — in order, before anything live. When the journal cannot supply
all of them (the client is further behind than the journal is deep, the
worker it reached was started after the frames went out, or the id is from
a server that has since restarted and numbered from 1 again) it is sent
none of them and one unnumbered event: m0-gap frame, whose data names the
id it presented and the newest id allocated; the live feed follows. A client
that listens for m0-gap and fetches the current state needs no poll
beside the stream. The journal is per process and in memory: a restart
begins it empty, and the gap frame is what a client reconnecting across
one receives. A first connection, without the header, starts from the live
feed. The Mojo-side DatastarStream keeps a journal of its own with the
same all-or-nothing rule (README, "SSE replay is journal-deep").
Work that has to outlive its request can publish from a child process the
view starts. Pass the bus and the event-id page by descriptor:
subprocess.Popen([...], pass_fds=m0pub.child_fds()). The child's frames are
then numbered from the same counter as every worker's, so a client that
reconnects with Last-Event-ID is not re-sent one it already has. With only
m0pub.bus_write_fds() passed, the child still publishes, unnumbered. Up to 1.3.0 that second form was unsafe: a child that
inherited the environment took an event id at an address in its parent's
memory, and either died with SIGSEGV or published a wrong id that
subscribers then dropped (#322). There, remove M0_SHARED_ID_ADDR from the
child's environment.
One framework-specific line: in Flask the socket route is declared
@app.route("/ws", websocket=True), because Werkzeug's router answers 400
to a request carrying Upgrade: websocket on an ordinary rule before any
view runs. Django has no such check. After the quickstart
has the Flask version of the whole thing, and CI drives that exact file.
In front of it
- Terminate TLS at a proxy. There is no TLS and no HTTP/2 here, by design. Fly, nginx, Caddy and a cloud load balancer all speak HTTP/1.1 to the app.
- A header whose name holds an underscore is dropped before a WSGI
application sees it, so behind HAProxy, Caddy or Traefik, which forward
such names, a client's
X_Forwarded_Forcannot replace the proxy'sX-Forwarded-For; an ASGI application under the executor receives both (Coming from uvicorn). - Held connections are kept alive through the proxy by default. Every
15 s an idle SSE stream gets a comment and an idle WebSocket a ping
(
M0_SSE_HEARTBEAT_MS, default15000). Set the variable only to change the cadence, and keep it below the proxy's idle timeout: a proxy that closes at 60 s is fine with the default, and25000would be too. - A client that vanishes from a quiet stream is dropped in about a
minute. A stream's socket has TCP keepalive on: after 15 s with
nothing received the kernel probes the client every 15 s, and closes the
connection at the third probe left unanswered. That covers the streams
the comment above does not reach: one your ASGI application writes
itself, while it has nothing to send, and any stream with the heartbeat
set to
0.M0_STREAM_KEEPALIVE_Ssets the seconds;0turns it off. A stream the server heartbeats is dropped when the heartbeat goes unanswered for as long as the kernel retries, about 16 minutes on Linux at its defaults. - Static files.
--static PREFIX=DIRserves a directory from the server withsendfile, ETags and byte ranges, never entering Python; a miss falls through to the application.--static-cache-control Vsets the header on successes.--static-header 'Name: value', repeatable, adds a header to every static response, errors included: the security headers an application's middleware sets, which static responses never pass through. Every static response carriesX-Content-Type-Options: nosniff. The type comes from the extension (the web's page, image, font, media and data formats, Markdown astext/markdown); an extension the server does not list is sent asapplication/octet-stream, which a browser downloads when the file is opened directly. An SVG carries aContent-Security-Policythat sandboxes it: opened directly or framed, a script inside it cannot act as your site, which matters for any SVG you did not write, uploads above all. In an<img>, a CSS background or a<use>sprite it renders as before. A--static-headernamingContent-Security-Policyreplaces it, on every static response. - Health.
--health-path PATHanswers a liveness JSON in the server. - IPv6.
--host ::listens on IPv6 and IPv4 at once, and--host ::1on the IPv6 loopback alone ([::1], as a URL writes it, is read the same). Fly's private network reaches an app on its.internaladdress only over IPv6, so an app a sibling calls there listens on::. An IPv4 client of::is reported as IPv4 (127.0.0.1), as a0.0.0.0listener reports it.
Observability
--access-logprints one JSON line per response:{"time":"2026-10-04T17:02:51.789Z","level":"INFO","msg":"access","method":"GET","path":"/","status":200,"dur_us":1234,"bytes":64,"remote_addr":"127.0.0.1"}.timeis the wall clock in UTC, with milliseconds.status,dur_us(microseconds from the request's headers to its response being sent; a stream's, to its head) andbytes(the body as sent, a file's included, nothing for a HEAD) are numbers.remote_addris the client's address as the application sees it, and is left out when the server could not read one.--metricsserves Prometheus exposition at/__metrics, with latency histograms.--doctorprints the whole resolved configuration as JSON and starts nothing. It exits with the code the server itself would use for the same arguments, so it answers "will this run?" in a CI step. The JSON names the platform, the interpreter and its virtualenv, the application spec discovery chose and the protocol it classified, the resolved topology, and achecksarray whose failures each carry adetail, afixand theexitthat check causes. With no application,m0serve --doctorchecks the environment alone.
Limits and lifecycle
--max-body SIZE(orM0_MAX_BODY) caps request bodies (default 4m;512k,64m,1g). A chunked body is bounded on the wire as well as decoded. Over the cap the server answers413itself, as plain text, before the application runs, so an application that formats its own errors (Flask'sMAX_CONTENT_LENGTHwith a JSON error handler, say) keeps that shape only with its own limit set below this one. The refusal goes out while the client is still uploading; the server then reads and discards the rest for up to five seconds, so a client that writes its whole body before it reads, ashttp.clientandrequestsdo, still gets the413. The first such refusal prints one line naming the cap and both knobs; the application's own log never sees the request.- A WSGI body the application did not size streams; a sized one is
buffered. A generator or iterator with no
Content-Length(Django'sStreamingHttpResponse, a FlaskResponse(generator)) is sent from a pool thread as it is produced: chunked on HTTP/1.1, close-delimited on HTTP/1.0,close()called, and the thread back in the pool when the client leaves. A body with aContent-Length(every Flask page, Django behindCommonMiddleware,FileResponse) and every list body is buffered and sent with its measured length, as is every body on a server with no pool (--blocking-threads 0). A generator that raises after its head ends the connection without the chunked terminator, so the client sees a truncated body rather than a complete one. --idle-timeout SECONDScloses idle keep-alive connections (default 60, 0 = never). It also bounds a WebSocket's wait for the peer's close reply and a refused upload's linger; at 0 both close at once. And it bounds a response the client stops reading: one that goes that long without a send making progress is closed, however long a response read steadily takes.--body-timeout SECONDS(orM0_BODY_TIMEOUT) refuses a request body still arriving that long after its headers (default 30, 0 = never):408on a connection's first request, then the connection closes. The first such refusal prints one line naming the timeout and both knobs.--max-keepalive-requests N(orM0_MAX_KEEPALIVE_REQUESTS) closes a keep-alive connection after N requests (0 = never). Every close is a reconnect for the client, and one reconnect per N requests is the client's tail at the 1/N quantile — which is how a cap of 100 was found to be the fast route's p99 on a loopback benchmark (notes/pool-tail.md).- SIGTERM drains. In-flight requests finish, held connections close, and
the process exits 0 well inside a container's stop grace. Under
--workers, signalling the supervisor reaps the workers. m0serve runs as PID 1 correctly. - Exit codes. 2 is a usage error, 1 a startup failure, 78 a configuration the interpreter cannot run; each prints one sentence naming the fix.
Configuration precedence
These flags have an M0_* variable: --host, --port, --workers,
--threads, --blocking-threads, --access-log, --qos,
--spawn-workers, --max-body, --body-timeout, --replay-frames,
--max-keepalive-requests and --pg-listen, each named in --help.
M0_SSE_HEARTBEAT_MS and M0_APP_TICK_MS have no flag. A flag beats the
variable, which beats the default. Flags are strict: --port 80eighty is
a usage error. Variables are lenient: M0_PORT=80eighty serves on the
default port, and m0serve prints a line at startup naming the value it
ignored and what it used instead. A port outside 1-65535, 0 among them, is
read and then refused with 78, whether --port or M0_PORT names it.
--doctor prints the values the server would use.
Coming from uvicorn
What a uvicorn command line does not show. m0serve --doctor APP prints
the values this server would use, and m0serve --help names every flag.
- A request body is buffered, and capped. It arrives whole before the
application runs, and one over
--max-body(M0_MAX_BODY) is answered413without reaching it. uvicorn streams bodies and caps none. Limits and lifecycle. - A body that stops arriving is refused after
--body-timeout(M0_BODY_TIMEOUT). uvicorn has no body timer. - An idle keep-alive connection is kept longer than uvicorn's
--timeout-keep-alivekeeps it:--idle-timeout. - The access log is off until
--access-log(M0_ACCESS_LOG), and is JSON lines (its fields). uvicorn logs requests by default, as text. - Forwarded headers are not applied. The client is the socket's peer
and the scheme
http;X-Forwarded-ForandX-Forwarded-Protoreach the application as ordinary headers, for it to trust (Django'sSECURE_PROXY_SSL_HEADER, Werkzeug'sProxyFix, or uvicorn's ownProxyHeadersMiddlewarearound an ASGI application). uvicorn applies them for the proxies--forwarded-allow-ipsnames. A header whose name holds an underscore never reaches a WSGI application, as under gunicorn: CGI's mapping would file a client'sX_Forwarded_Forunder the proxy'sHTTP_X_FORWARDED_FOR. An ASGI application under the executor receives both spellings as sent, as the ASGI spec requires; under--blocking-threadsabove 0 its scope is built from the same environ on the buffered path, and underscore names are dropped as for WSGI. - A lifespan startup that fails exits 1, where uvicorn exits 3 (Install and start).
- ASGI needs a GIL-enabled CPython. A free-threaded build, which uv may
pick for a project with no pin, is refused with exit 78
(why). With uv, put
3.13in.python-version; the refusal says so too.
Platforms
macOS arm64 (13+), Linux x86_64 and aarch64 (glibc; the exact floor is in
the wheel filename, and pip declines an older system rather than crash).
CPython 3.10 to 3.14, free-threaded builds included for WSGI. No Windows, no
musl, no Intel Mac.