m0serve Realtime from a synchronous Python app

mojo-http

The repository's own README: the realtime server, its install matrix and limits, and the Mojo web framework, Datastar adapter and SQLite bindings m0serve is one package of.

Tests Docs

Realtime from a synchronous Python app, with no added infrastructure.

(New here? The documentation site is m0serve.dev: the quickstart, the operations guide and the capability matrix, with docs/README.md as the map. For where the project stands, run uv run poe milestones — it computes what is left for 1.0 rather than reporting what someone last wrote down.)

A plain sync Django or Flask view can hold a Server-Sent Events stream or a WebSocket by answering with two response headers, and reach every subscriber on every worker with one function call. No Channels, no Redis, no daphne, no Pushpin, no second process.

from m0serve import m0pub


def events(request):                    # an ordinary synchronous view
    r = HttpResponse(": connected\n\n", content_type="text/event-stream")
    r["M0-Hold"] = "stream"             # m0serve holds the connection open
    r["M0-Channel"] = "news"            # ...subscribed to this channel
    return r                            # Django's part in it ends here


def announce(request):                  # any view, command, or cron job
    m0pub.publish("news", "deploy finished")
    return HttpResponse("ok")
pip install m0serve
m0serve myproject.wsgi --realtime

The view runs first, with sessions and permissions in hand — which is where your auth belongs, and why this is a feature of your app rather than of a sidecar. Under gunicorn the two headers mean nothing (they are passed through to the client) and the same view degrades to a short plain response, so adopting it is not a fork of your codebase. The Flask version is the same four views with one extra flag: the socket route is declared websocket=True, because Werkzeug's router refuses an upgrade request on an ordinary rule before any view runs — QUICKSTART §7 has it.

QUICKSTART.md is ten minutes from pip install to live multi-tab sync, and CI executes every command in it on every pull request — so it works, or the build is red.

Two more things it does

  • Several applications in one process, each in its native mode. m0serve --mount /=shop.wsgi --mount /app=live.asgi runs sync Django on handler threads and async FastHTML on an asyncio executor, behind one listener with one shutdown. With four blocking 2-second sync views holding every pool thread, the async mount still answers at p99 2.8 ms.
  • It runs the app you already have. WSGI or ASGI, detected from the object. Django, Flask and FastHTML each have their own smoke test in CI (FastHTML is Starlette-based, and is the flagship ASGI row), alongside bare WSGI and ASGI apps that pin the specs clause by clause. PEP 3333 conformance is validated by wsgiref, ASGI by a validator written from the spec.

What it is not

  • No TLS and no HTTP/2. Terminate at a proxy — gunicorn's answer, and the same one applies here.
  • Not the fastest server on raw throughput, and docs/BENCHMARKS.md says so with numbers: ~0.85x Granian per measured core on bare WSGI, 1.06x uvicorn on ASGI at 16 connections (0.74x against uvicorn with uvloop) and 1.22x at 256. What it does win is the fast-request tail under mixed load. Every figure there cites a dated artifact and CI recomputes the prose against it.
  • Pre-1.0, and the API will change (CHANGELOG).
  • macOS arm64 and Linux x86_64/aarch64 only. No Intel Mac (no toolchain), no Windows, no musl.

Underneath, mojo-http is an HTTP/1.1 server and a small web framework for Mojo: routing, content negotiation, ETags, response caching, and Server-Sent Events, with a Datastar adapter for hypermedia UIs and SQLite bindings for storage. The Python server above is one package in it (m0-wsgi), and the rest of this README is the Mojo side.

The server itself is a hard fork of lightbug_http, taken from v26.1.2 and maintained here since upstream was archived on 2026-05-12 — not a vendored snapshot. It adds hardening against request smuggling, slowloris, and integer overflow in request parsing, connection timeouts, an SSE- and WebSocket-aware event loop, and a fix for epoll struct layout on non-x86_64. See NOTICE for the full record.

Install

To serve a Python application, no Mojo toolchain is needed — install the server binary from PyPI and point it at your app:

pip install m0serve
m0serve myproject.wsgi:application

Install it into the same virtual environment as your application, the way you would gunicorn or uvicorn. The protocol is detected from the object, so the same command serves WSGI and ASGI.

Then: QUICKSTART.md — ten minutes from pip install to live multi-tab sync from one synchronous Django file. Every command in it is executed by CI on every pull request, so it works or the build is red.

One wheel per platform covers every supported CPython, 3.10 through 3.14 including free-threaded builds. That is not a shortcut: m0serve does not link libpython — Mojo dlopens the interpreter at run time — so there is no CPython ABI in the wheel to be compatible with, and no CPython inside it to redistribute. It has no Python dependencies and fetches nothing at install time.

platform status
macOS arm64 (Apple Silicon), macOS 13+ supported
Linux x86_64, glibc supported
Linux aarch64 (Graviton, Ampere, arm64 Docker) supported
macOS x86_64 (Intel) not possible: Modular ships no Intel Mac toolchain
musl / Alpine, Windows not supported

The exact floors live in the wheel filename, because they are measured from the built binary rather than copied from the toolchain's own tag. macOS is pinned at 13.0; Linux currently measures manylinux_2_35, so Ubuntu 22.04, Debian 12 and newer. An older distribution is declined by pip rather than installed and crashed at startup — RHEL 9 and its rebuilds sit at glibc 2.34 and miss by one minor version. Reaching them means building inside a manylinux_2_34 container rather than relabelling the artifact.

To develop against the Mojo packages, you do need the toolchain:

uv sync                     # installs the Mojo toolchain
uv run poe serve-hello      # http://localhost:8080
curl localhost:8080/health  # {"status":"ok"}

The whole server

apps/hello/server.mojo, in full:

from lightbug_http import Server, HTTPService, HTTPRequest, HTTPResponse, OK


@fieldwise_init
struct HelloHandler(HTTPService):
    def func(mut self, req: HTTPRequest) raises -> HTTPResponse:
        if req.uri.request_uri == "/health":
            return OK('{"status":"ok"}', "application/json")
        return OK("hello from m0", "text/plain")


def main() raises:
    print("Starting hello server on 0.0.0.0:8080")
    var server = Server()
    var handler = HelloHandler()
    # The non-blocking loop multiplexes keep-alive connections instead of
    # serving one at a time — measurably better tail latency under
    # concurrent clients, and the same one-liner to call.
    server.listen_and_serve_nonblocking("0.0.0.0:8080", handler)

The four sse_* hooks are the streaming interface (shared by SSE and WebSocket slots), tick is the opt-in timer hook, and ws_message receives WebSocket messages; a handler that uses none of them returns the empty defaults shown here.

What's in the box

Package Description Tests
m0-core FNV-1a, xxHash32, wyhash64, SIMD JSON escape, JSON field parser, C-ABI exports 82
m0-http Router, content negotiation, ETag, response cache, SSE, WebSockets, auth, CORS, config, health, logging, multi-worker supervisor, cross-worker broadcast bus, HTTP client, request-parsing hardening 588
m0-datastar Datastar v1.0.2 wire format, DatastarStream fan-out with Last-Event-ID replay and cross-worker broadcast, read_signals 73
m0-wsgi WSGI/ASGI gateway — run Django, Flask, FastHTML, or any WSGI/ASGI app on this server 156
m0-sqlite SQLite bindings — connections, statements, typed columns, transactions, bulk read-out, array virtual table 115
Total 1014

Modules are named m0_*mojo-http is the repository, m0 is the import prefix.

m0-core's hash functions are also exported over a C ABI: uv run poe build-ffi emits packages/m0-core/libm0core.so (.dylib on macOS) for Bun's dlopen, Node's N-API, or Python's ctypespoe smoke-ffi proves that path against public hash vectors in CI, and prebuilt Linux/macOS artifacts ship with each GitHub release.

Each release ships two macOS assets: libm0core-macos-arm64.dylib, the bare library for anyone who already has a Mojo install, and libm0core-macos-arm64.dylib.tar.gz, a self-contained bundle (1.65 MB) that carries the three Mojo runtime libraries it loads, plus both licences. Extract it and dlopen the .dylib — nothing else needed. The Linux .so is statically linked and self-contained on its own.

poe bundle-ffi builds that bundle and refuses to finish unless the result is genuinely self-contained; CI runs it on every commit, so a release cannot ship an asset that only loads on the build machine.

Releases up to and including v0.7.0 predate this and record the CI runner's own directory, so their macOS asset does not load anywhere else. Build locally with poe build-ffi for a usable one, or use v0.8.0 onward.

Strict layering, no upward imports: m0-core has zero dependencies and m0-http uses three functions from it. m0-datastar splits in two — consts and sse are the pure wire format with no dependencies at all, while stream and signals are the server glue and are the only parts that pull in m0-http. m0-wsgi is the only package that embeds CPython, which is exactly why it is a separate package.

HTTP essentials — path router with :param extraction · content negotiation with quality factors, case-insensitive media ranges, and wildcards · Accept-Encoding negotiation (codec-agnostic: it picks among the precompressed codings you can serve, with the RFC 9110 identity/*/q=0 rules, and tells you when the honest answer is 406) · Accept-Language negotiation (RFC 4647 matching — de finds your de-CH, en-US falls back to your en — preferring to serve something over a 406, as RFC 9110 advises) · weak ETags (wyhash) with 304 Not Modified · URL-keyed response cache · static file serving with lexical traversal defense, extension content types, ETag/304, and single byte ranges (206/416) · SSE with backpressure and Last-Event-ID reconnect replay · WebSockets (RFC 6455): handshake, fragmented messages, UTF-8 validation of text (1007), protocol-error refusals, ping/pong heartbeats, clean close.

Production bits — API key auth with constant-time comparison · CORS config · M0_-prefixed env-var configuration · health/readiness registry with a shutting-down flag · JSON-lines access logs to stdout · graceful shutdown on SIGTERM/SIGINT that drains in-flight requests, propagated to workers when only the supervisor is signalled · multi-worker fork supervisor (M0_WORKERS=4) — workers accept from one shared pre-fork listener, with a cross-worker SSE broadcast bus when the app wires it in.

Outbound tooClient speaks HTTP/1.1 the other way: client.get(url) / client.post(url, body) with DNS, timeouts, keep-alive connection reuse (framing boundaries computed per response — Content-Length, chunked with trailers, bodiless statuses, HEAD — with conservative retirement rules and a single stale-connection retry), and full response parsing with loud truncation detection. No TLS, no redirect following — the same honest constraints as the server, documented in client.mojo. poe smoke-client proves a six-request conversation rides one TCP connection in CI.

Most of that composed, in one small app: apps/notes_api/ — CRUD with :id routes and a real 405 with Allow, the same note negotiated as JSON or HTML by the Accept header, ETag/304, static files under /static/ (traversal probes get a 404, asserted with curl --path-as-is), RFC 9457 problem+json on every error, CORS from a single after_response hook, and M0_PORT config. uv run poe serve-notes runs it; poe smoke-notes asserts each feature end to end.

Datastar

m0-datastar speaks the Datastar v1.0.2 wire format, and DatastarStream connects it to the server. A handler holds one, wires the four SSE hooks through it, and broadcasts after a mutation:

struct CounterHandler(HTTPService):
    var count: Int
    var stream: DatastarStream

    def func(mut self, req: HTTPRequest) raises -> HTTPResponse:
        if req.uri.path == "/events":
            return self.stream.open(req, "/events")       # opens the SSE stream
        if req.uri.path == "/increment":
            self.count += 1
            _ = self.stream.patch_signals(                 # reaches every open tab
                "/events", '{"count":' + String(self.count) + "}"
            )
            return HTTPResponse(body_bytes=String("").as_bytes(), status_code=204)
        ...

    def sse_drain_slot(mut self, slot: Int) -> List[UInt8]:
        return self.stream.drain(slot)
    def sse_is_streaming(self, slot: Int) -> Bool:
        return self.stream.is_streaming(slot)
    def sse_slot_disconnected(mut self, slot: Int):
        self.stream.closed(slot)
    def sse_peer_frame(mut self, url: String, event_id: Int, frame: List[UInt8]):
        self.stream.deliver_peer(url, event_id, frame)   # cross-worker fan-out

read_signals(req) is the other direction — the browser posts its whole signal store, as a datastar query parameter on GET and as the body otherwise.

Run apps/datastar_counter/ with uv run poe serve-counter and open it in two tabs; pressing a button in one updates the other. It is also the reference wiring for cross-worker fan-out: with M0_WORKERS=2 it creates a BroadcastBus and a shared-memory counter before the fork, and a button press handled by any worker updates tabs connected to every worker (poe smoke-counter proves its streams span workers before asserting exactly that). apps/datastar_todo/ is the same idea grown up: mutations broadcast rendered HTML (patch_elements morphs <section id="todos"> by id in every tab), the per-item actions are Router routes with :id, todo text is HTML-escaped before it is broadcast, and the list is rows in SQLite (M0_DB, default todos.db) — kill the server and restart it, the list comes back. So does the stream: broadcast frames are logged to SQLite and restored into the DatastarStream journal at boot, so a tab reconnecting with Last-Event-ID is caught up by the new process instead of waiting for the next mutation. poe smoke-todo asserts both. uv run poe serve-todo.

A note on Datastar v1.0.2 attribute syntax, learned the hard way in a real browser: the stream opens from data-init (there is no on-load plugin), and keyed attributes are colon-separated — data-on:click, data-bind:draft. The hyphenated forms fail silently.

SSE and WebSockets need listen_and_serve_nonblocking, not listen_and_serve. Only the non-blocking event loop assigns req.slot_id, drains the outbox, and parses WebSocket frames; the plain accept loop leaves slot_id at -1 and every stream open answers 409.

WebSockets

apps/ws_echo/ shows the whole contract in one screen. websocket_upgrade(req) answers the opening handshake inside func (101 on success, 426/400 for near-misses, None when the request isn't an upgrade at all so ordinary routing continues); ws_message(slot, opcode, payload) receives each complete message — fragments already assembled, control frames already answered by the event loop — and replies are queued as encode_ws_frame(...) bytes that the shared outbox hook delivers. Idle sockets get protocol pings on the M0_SSE_HEARTBEAT_MS cadence, and every close path — close handshake, vanished client, failed ping — lands in sse_slot_disconnected. Run it with uv run poe serve-ws; poe smoke-ws proves the wire format against a from-scratch stdlib client, from the accept key to the closing TCP FIN.

apps/ws_chat/ grows that into the multi-worker shape: one chat room, every message reaching every socket, across workers. m0_http.WSHub is the handler-side registry (who is connected, what each socket should be sent), and under M0_WORKERS>1 it rides the same BroadcastBus the SSE counter uses — the bus never cared what its payload bytes were. uv run poe serve-chat with M0_WORKERS=2, open a few tabs; poe smoke-chat proves a message sent on one worker's socket arrives on the other worker's, over the bus.

Django, and anything else that speaks WSGI

m0-wsgi embeds CPython and runs a WSGI application, so mojo-http can stand in for gunicorn. The whole integration is one field and one call:

from m0_wsgi import WSGIApp

struct DjangoHandler(HTTPService):
    var app: WSGIApp

    def func(mut self, req: HTTPRequest) raises -> HTTPResponse:
        return self.app.serve(req)
    ...

def main() raises:
    var app = WSGIApp(
        "djangoproj.wsgi", server_name="0.0.0.0", server_port="8080",
        project_path="apps/django_wsgi",
    )
    Server().listen_and_serve("0.0.0.0:8080", DjangoHandler(app^))

That handler is written once, as WSGIHandler, and m0serve is the binary that runs it — the uvicorn-shaped entry point:

uv run poe build-serve                                     # -> bin/m0serve
bin/m0serve myproject.wsgi:application --app-dir /path/to/project \
    --host 0.0.0.0 --port 8000 --workers 4 \
    --static /static/=/path/to/static --static-cache-control 'public, max-age=3600'

It prints one line per worker when it is up:

🔥 m0serve: myproject.wsgi:application on http://0.0.0.0:8000 (protocol=wsgi workers=4) blocking-threads=8 (auto)

That line is the ready signal, and it is printed after the application has been imported — so a project whose import fails prints a traceback and exits 1 rather than announcing itself first. Startup failures and the shutdown report use the same m0serve: prefix without the flame. Nothing else is written to stdout unless --access-log is on.

Do not scrape it from an orchestrator, though: readiness there is --health-path /health (a probe endpoint answered in Mojo, before the application) or a plain TCP check on the port, and m0serve --doctor for a configuration report that exits with the code the server itself would use.

MODULE[:ATTR] names the callable (ATTR defaults to application, and a bare MODULE also tries MODULE.asgi, MODULE.wsgi, MODULE:app and MODULE.main:app by convention); the protocol is detected from the object — a coroutine-function callable is served as ASGI, anything else as WSGI, --protocol overrides — so bin/m0serve main:app runs a FastHTML, Starlette, or FastAPI app from the same binary, with real await-concurrency on a per-loop asyncio executor — requests overlap wherever the application awaits, streaming responses stream for real, and websocket scopes work, so FastHTML's whole surface runs with no configuration (the design record §8). --app-dir is prepended to sys.path so the module imports, relative to the current directory and defaulting to ..

--mount PREFIX=SPEC hosts several applications in one process, routed by longest prefix before either sees the request:

bin/m0serve --mount /=shop.wsgi --mount /app=live.asgi \
    --app-dir apps/hybrid_mix --port 8099

Each mount runs in its own native execution mode — the sync Django app on handler-pool threads, the async FastHTML app on the asyncio executor — sharing one listener, one set of workers and one graceful shutdown. With four blocking 2-second Django views holding every pool thread, the async mount still answers at p99 2.8 ms. uvicorn, daphne and Granian each host exactly one callable, so mixing otherwise means two processes behind a proxy.

Each mount detects its own protocol and gets its own bridge, and the prefix reaches the application the way its protocol expects it — SCRIPT_NAME with PATH_INFO trimmed for WSGI, root_path with the whole path for ASGI — so reverse() and url_for() generate links that actually work. A path no mount claims is a 404 answered in Mojo, never entering Python. Any mix: every ASGI mount gets its own executor, any number of WSGI mounts share the pool (the design record §9). Every M0_* variable keeps its meaning (M0_HOST, M0_PORT, M0_WORKERS, M0_ACCESS_LOG, …) with the matching flag winning over it, and flags are strict: --port 80eighty is a usage error, not a silent default. --max-body and --metrics reach two server tunings the environment cannot; --blocking-threads N puts a pool of handler threads behind each event loop so a slow view stops holding the connections pinned behind it — and when no topology flag or variable is given at all, the protocol picks the default: WSGI gets a pool of min(cores, 8), ASGI gets the asyncio executor (any explicit value wins, M0_BLOCKING_THREADS=0 restores the WSGI single loop, --realtime keeps the single loop); and --reload [--reload-dir DIR] re-forks the workers onto changed Python in ~300 ms without re-exec'ing the binary. --help has the rest; exit codes are 2 for a bad command line and 1 for an application that would not load — including under --workers N, where a supervisor that gives up on respawning says so instead of exiting 0.

--doctor answers "will this run?" without running it. It prints one JSON object — platform and wheel architecture, the interpreter it resolved and the virtualenv it came from, the spec discovery actually chose and the protocol it classified as, the resolved topology, and a checks array whose failures each carry a detail, a fix and the exit code that check would cause — then starts nothing: no bind, no fork, no second import.

$ m0serve --doctor myproject.wsgi --threads 4 | jq '{exit, checks: [.checks[] | select(.ok | not)]}'
{
  "exit": 78,
  "checks": [
    {
      "name": "free-threading",
      "ok": false,
      "detail": "--threads 4 requires free-threaded CPython with the GIL disabled; this is not a free-threaded build",
      "fix": "use --workers N instead, or run on 3.14t with PYTHON_GIL=0",
      "exit": 78
    }
  ]
}

The contract worth relying on is the exit code: --doctor exits with the code m0serve itself would exit with for the same arguments. That is kept true by running both over every refusal and comparing (poe smoke-doctor), because the doctor mirrors the startup path's check order rather than sharing its control flow — a diagnostic that reports "fine" where the server refuses would be worse than none. A bare m0serve --doctor with no application is the environment check: platform, interpreter, cores, and exit 0.

The binary resolves libpython from the python3 on PATH: run it from a virtualenv that has your framework installed, or set MOJO_PYTHON_LIBRARY to the shared library. Build once, serve anywhere the interpreter is.

apps/django_wsgi/ is a real Django project with no Mojo in it, served by exactly that command (uv run poe serve-django).

apps/flask_wsgi/ is the same binary running Flask instead. Both rows run the same assertions — scripts/wsgi_framework_contract.sh — because routing, cookies in both directions, body round trips and error handling are things every WSGI framework does identically. Adding Flask needed no change to m0-wsgi at all, and the rows are now Python-only: one binary serves all three, which is the host-is-framework-agnostic claim made structural.

apps/wsgi_bare/ is the same binary with no framework at all — a plain PEP 3333 callable, zero third-party imports. It is what makes the claim above demonstrable rather than merely asserted, and it is the conformance target for uv run poe smoke-wsgi, which checks the parts of the spec a framework never exercises: the write() callable, a second start_response, multi-chunk iterables, wsgi.input read patterns, and the CGI environ transform. See docs/WSGI_CONFORMANCE.md.

apps/django_realtime/ is the row that answers the question WSGI is usually retired over. A synchronous Django view holds a connection by answering with headersM0-Hold: stream for SSE, M0-Hold: websocket for a WebSocket — and the Mojo layer takes it from there: the 101 handshake Django cannot emit, the heartbeats, the disconnect cleanup, the fan-out. Inbound WebSocket messages come back to Django as ordinary POSTs. Publishing is os.write from pure Python onto the server's broadcast bus, so one line in a sync view reaches SSE clients and WebSocket clients on every worker, with numbered event ids that make Last-Event-ID work. No ASGI, no Channels, no async. The pattern is Pushpin's GRIP collapsed into one process; the reasoning, the measurements, and the remaining limits are in docs/WSGI_VS_ASGI.md. Like the other WSGI rows it is a Python-only project: m0serve --realtime --health-path /health is the whole server side of it. Run it with uv run poe serve-django-realtime.

Why the boundary looks the way it does. WSGI hands the application a start_response callable that the server supplies, and building a Python callable that closes over Mojo state is the hardest thing at this boundary — so a small Python shim does it instead. The shim is a string exec'd at startup, not a file, so there is nothing to locate at run time. Mojo builds each request's WSGI environ itself, through the raw CPython C API — PyDict_New, PyDict_SetItem, PyUnicode_DecodeUTF8 — and hands the finished dict to the shim, which supplies start_response, calls the application, and returns (status, headers, body).

The C API is not a micro-optimization but the only door available: Mojo 1.0's PythonObject interop leaks a reference per call argument and per __setitem__ value, so any per-request Python object passed the obvious way is pinned forever. The C API refcounts explicitly, which is what lets the environ be built at all — and it is why every string is Py_DecRef'd after PyDict_SetItem takes its own reference. poe smoke-django asserts the result: flat memory across 10k requests. Building the environ here rather than in Python is also worth 1.57x end to end (docs/WSGI_PERFORMANCE.md).

Bodies cross through the same door: std.python binds no bytes API, but the stdlib's own symbol loader reaches the functions it left out, so the request body becomes a real bytes via PyBytes_FromStringAndSize (one copy, and io.BytesIO(bytes) shares it rather than copying again) and the response body is read back through PyBytes_AsString. A String round trip would corrupt any byte above 0x7F; a smoke asserts a body of all 256 byte values returns unchanged.

Limits, all inherited from the server rather than the bridge:

  • Concurrency is loops, and optionally a handler pool behind each. --workers N preforks N processes that all accept from one shared listener, gunicorn-style — the fork happens before the first Python call, never after, because forking a live CPython is unsafe — or, on free-threaded CPython (3.14t or newer with the GIL off — 3.13t is a dead end that systematically immortalizes objects, which is why Django dropped it from its own CI), --threads N runs N loops on N threads in one process: one RSS, the app imported once, and none of the fork-after-init hazards. A GIL-enabled interpreter refuses --threads outright (exit 78) rather than run loops the GIL would serialize.

    Either way a keep-alive connection stays pinned to the loop that accepted it, and by default that loop calls HTTPService.func itself — so one slow view stops every connection it holds. That is measurable and it is large: one slow view beside fast traffic leaves fast-request p99 ~120x worse in both modes while p50 does not move at all. --blocking-threads N is the fix, and it composes with either mode: the loop becomes an acceptor that hands each request to one of N handler threads and goes straight back to waiting, so no connection is hostage to whichever request some other connection is running. On apps/wsgi_bare a fast request is answered in 1 ms with two 1.5 s views in flight, against 2.7 s for the same server without the flag. It works on a GIL-enabled interpreter too — a view waiting on a database or a socket releases the GIL, which is the workload it exists for — and it composes with --realtime: a hold a pool thread takes is forwarded to the loop's registries, so a held-stream server no longer runs its views on the loop and one slow view no longer stalls every stream on that worker. --mount composes with --realtime too, so one process can hold the streams a synchronous app publishes to and run an ASGI mount for the streams whose view is the producer. Sockets travel the same seam: a pool thread performs the 101, and inbound frames come back to the mount whose view gated the upgrade.

    Benchmarked against gunicorn at 1.4–1.5x its throughput on a GIL-enabled 3.13 container and ~3.5x on free-threaded 3.14.7t, with comparable-or-better p99 in both keep-alive and close-per-request modes. Against Granian, whose own --blocking-threads is the architecture copied above, m0serve is behind on raw WSGI throughput and the gap is located: normalized per measured core, 85.2k against 100.0k rps/core on a bare callable — about 0.85x. The split says where it goes. apps/hello, the same server with no Python in the path, runs at 115.9k rps/core — above Granian's end-to-end rate — so m0serve's own bridge costs 1.36x, and essentially all of the deficit is that crossing rather than HTTP parsing or the event loop. How much of Granian's rate its own bridge costs is not measurable from this run: there is no Granian-without-Python row.

    Per core, because the comparator was not running one: Granian's --workers 1 was measured at ~1.75 cores across its runtime's I/O threads, so raw-rps ratios had been comparing 1.75 cores against one. Every number here cites a dated artifact — docs/BENCHMARKS.md is the page, and it states the ASGI comparison against uvicorn (also a loss) beside this one; docs/WSGI_PERFORMANCE.md is the working record, including the leak that once made this paragraph less flattering and the re-measurement that retired its previous numbers.

  • Unsized WSGI bodies stream; sized ones buffer. A generator or iterator the application did not size — Django's StreamingHttpResponse, a Flask Response(generator) — streams from a --blocking-threads pool thread (the WSGI zero-config default) through the same chunk channel the ASGI executor uses: chunked on HTTP/1.1 with the connection reusable after, close-delimited on HTTP/1.0, close() called, and the thread back in the pool when the client leaves. A body the application sized (Content-Length — every Flask page, every Django page behind CommonMiddleware, FileResponse) and every list body is buffered and sent with its measured length, as before; so is everything on a server with no pool (--blocking-threads 0, unmounted --realtime), where producing a body on the loop thread would be the hostage problem again. A generator that raises after its head truncates the body honestly — the connection closes without the chunked terminator. Still buffered: an iterator that carries its own Content-Length (FileResponse), the recorded follow-up.

  • Request bodies are fully buffered too, capped by ServerConfig.max_request_body_size (4 MB default). Raise it for uploads.

  • No TLS. wsgi.url_scheme is always http; terminate at a proxy and set Django's SECURE_PROXY_SSL_HEADER.

  • Django is a dev dependency here, for the example and its smoke test. The package itself has no opinion about which WSGI framework you run.

SQLite

m0-sqlite is a thin, honest layer over the SQLite C API — no ORM, no query builder, no connection pool. It is a sibling of the HTTP packages, not a layer on them, and imports nothing else in this repo.

from m0_sqlite import open_memory

var db = open_memory()
db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")

var ins = db.prepare("INSERT INTO users (name) VALUES (?)")
ins.bind_text(1, "ada")        # parameters are 1-based
_ = ins.step()

var q = db.prepare("SELECT id, name FROM users")
while q.step():                # columns are 0-based
    print(q.column_int(0), q.column_text(1))

Connection and Statement own their handles and release them on destruction. Both are Movable but not Copyable, so a handle cannot be duplicated into a second owner that would close it twice — move with ^ to transfer ownership. open() applies the pragmas a server actually wants: journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, plus a 5-second busy timeout so a contended write waits instead of failing on contact. Transactions are explicit (begin / commit / rollback); there is no scope guard, because Mojo has no defer and a destructor that rolled back would make correctness depend on drop order.

Wrap bulk writes in a transaction. Ten thousand inserts take 7 ms inside one begin/commit and 325 ms without — autocommit gives each row its own transaction. It is a 46x difference, and the largest single effect measured in docs/SQLITE_PERFORMANCE.md, which also covers mmap_size, variable-length IN lists, and how m0_array relates to the carray() extension it replaces.

db.begin()
for row in rows:
    ins.reset()
    ins.bind_text(1, row)
    _ = ins.step()
db.commit()

Reading in bulk. column_blob copies with one memcpy, and column_blob_into reuses a caller buffer across a scan instead of allocating per row (3.3x on 4KB blobs over 100k rows). fetch_ints / fetch_floats / fetch_texts append a whole column into a caller-owned List; they are a shape convenience rather than a speed-up — SQLite has no bulk column API — but a List per column is what a SIMD pass over the results wants. They signal exhaustion with a short read, so stop when you get fewer rows than you asked for rather than looping until zero.

prepare() compiles exactly one statement; text after the first — or text that compiles to nothing, like a lone comment — is an error rather than silently ignored. Use execute() for a multi-statement script.

Errors carry a code. Every failure that had a SQLite result code raises a message ending in (rc=NN), and error_code() recovers it, so retrying a SQLITE_BUSY or reporting a SQLITE_CONSTRAINT does not mean parsing text. The codes worth branching on are exported by name.

from m0_sqlite import error_code, SQLITE_BUSY, SQLITE_CONSTRAINT

try:
    db.begin_immediate()
except e:
    if error_code(String(e)) == SQLITE_BUSY:
        ...

Column indices are checked. SQLite calls an out-of-range index undefined behaviour and in practice answers 0, "" and SQLITE_NULL to it — so a typo used to read back as a stored NULL, and column_name dereferenced the NULL pointer it got and crashed. Every reader now raises instead. It costs one sqlite3_column_count per cell, measured at 1.02 ns/row.

open() raises on a target that cannot do WAL — :memory:, a temp database, some network filesystems — rather than quietly falling back to a rollback journal and delivering none of the concurrency it advertises. Use open_memory() for an in-memory database.

Linking. Tests build to a binary with -Xlinker -lsqlite3 rather than using mojo run. The JIT resolves symbols only from libraries already in its process: on macOS libsqlite3 lives in the dyld shared cache so mojo run happens to work, but on Linux it fails with JIT session error: Symbols not found: [sqlite3_open_v2, ...]. Linking explicitly behaves the same on both. Linux also needs libsqlite3-dev present at link time; CI installs it.

Bulk arrays. m0_array(?) is an opt-in virtual table that streams a Mojo List into SQL without copying it, so N rows insert in one sqlite3_step instead of N — measured at ~3x against the per-row bind/step/reset loop at both 10k and 200k rows (uv run poe bench-sqlite).

db.register_array_module()          # per connection, not per process
var ins = db.prepare("INSERT INTO t (v) SELECT value FROM m0_array(?1)")
ins.execute_over(1, values)         # binds, runs to completion, unbinds

Arrays bind only through execute_over and fetch_ints_over, and that is a safety property rather than a style preference. Binding a raw pointer lends SQLite the buffer for the statement's life, but Mojo frees a value at its last syntactic use — which would be before step() runs. Taking the array as an argument to the call that also finishes the statement is what keeps it alive; there is no bind_array to get wrong. The trade is that these do not compose with incremental stepping. See docs/sqlite-vtab-feasibility.md for the measurements and the reasoning, including why this is worth it for ingest and not for IN clauses. Needs SQLite 3.26+; register_array_module says so if not.

Not implemented: statement caching. It was measured in the benchmark that chose SQLite and came out within noise at realistic row counts (~10% at N=50), so it is not worth the ownership complexity yet.

Status and limits

docs/SPEC.md is the full capability matrix — every row carries its evidence, and poe check-docs fails if a row claims a CI gate that does not exist or does not run. The bullets below are the short version.

  • HTTP/1.1 only. No HTTP/2, no TLS — terminate at a proxy.
  • Linux x86_64 and aarch64 (epoll), macOS arm64 (kqueue). Architectures matter here: Modular ships no Intel Mac toolchain, so macOS x86_64 is not buildable at all. See the install table above.
  • Mojo 1.0, pinned in uv.lock. .mojoc artifacts are locked to the exact compiler that produced them, so rebuild after any toolchain change.
  • Building on Linux needs three system packages: a C compiler (mojo build shells out for linking), patchelf (the binaries record a $ORIGIN DT_RUNPATH so they find the Mojo runtime beside themselves), and libsqlite3-dev for m0-sqlite. build-essential libsqlite3-dev patchelf covers it. None are needed on macOS.
  • m0-wsgi needs a discoverable libpython (Python 3.10–3.14; this repo pins 3.13). Mojo resolves the interpreter from PATH, which is why the poe tasks — running inside the venv — pick up the venv's Python and its packages.
  • Pre-1.0: the API will break.
  • SSE fan-out is single-process by default. M0_WORKERS>1 forks, and each worker gets its own subscriber registry. The BroadcastBus lifts this when wired in: created before the fork (one datagram channel per worker, alongside a SharedAtomics slot that keeps event ids unique across workers), it carries every broadcast to every worker's subscribers — apps/datastar_counter is the reference wiring, asserted by poe smoke-counter. Cross-worker ordering is best-effort: two workers broadcasting concurrently can reach a subscriber in either order, and the redelivery filter keeps the newer id.
  • Server-initiated pushes go through tick. The tick(now_ms) hook fires every M0_APP_TICK_MS milliseconds (0, the default, disables it) on the event loop's own timer — broadcast from it and the same loop pass delivers, no inbound request involved; the counter demo's live uptime clock is the reference. It runs on the event loop thread, so keep it quick; handlers with slower cadences sub-schedule off now_ms. (Idle-stream : heartbeat comments are separate and automatic, every M0_SSE_HEARTBEAT_MS.)
  • m0-sqlite has no statement cache and no connection pool; see above.
  • SSE replay is journal-deep. DatastarStream honours Last-Event-ID from a bounded in-memory frame journal (default 64 frames); a client further behind than that resumes live instead of being caught up. In-process replay works out of the box — replay across a restart additionally needs the app to persist the journal and restore it at boot, which the todo demo does (SQLite events table, ~15 lines).

Development

uv run poe                  # list every task
uv run poe build-all        # compile each package to .mojoc
uv run poe test-all         # 1014 unit tests, then compiles every example
uv run poe serve-notes      # the framework showcase (notes CRUD) on :8080
uv run poe serve-counter    # the Datastar counter demo on :8080
uv run poe serve-todo       # the Datastar todo demo (multi-tab sync) on :8080
uv run poe serve-django     # the Django WSGI example on :8080
uv run poe serve-wsgi-bare  # the framework-free WSGI example on :8086
uv run poe serve-flask      # the Flask WSGI example on :8087
uv run poe build-site       # render the docs site from these pages into dist/site
uv run poe serve-site       # ...and serve it on :8180 with m0serve, llms.txt at the root
uv run poe smoke-hello      # start the hello server, assert /health, stop
uv run poe smoke-notes      # assert routing, negotiation, ETag/304, static files, CORS
uv run poe smoke-ws         # speak RFC 6455 raw: handshake, echo, fragments, ping/pong, close
uv run poe smoke-chat       # one chat message reaches sockets on BOTH workers, over the bus
uv run poe smoke-counter    # assert SSE broadcast, heartbeats, disconnect cleanup, app tick, fan-out
uv run poe smoke-todo       # assert broadcasts, restart survival, and Last-Event-ID replay
uv run poe smoke-client     # run the Mojo HTTP client against a Mojo server
uv run poe smoke-wsgi       # PEP 3333 conformance against a bare WSGI callable
uv run poe smoke-flask      # the same framework contract, against Flask
uv run poe smoke-django     # assert a Django request/response cycle end to end
uv run poe build-ffi        # emit the C-ABI shared library (libm0core.so/.dylib)
uv run poe smoke-ffi        # load the shared library via ctypes, assert known vectors
uv run poe bench-core       # benchmark m0-core hot paths
uv run poe bench-sqlite     # benchmark m0-sqlite blob reads and bulk ingest

Cross-package imports resolve through the .mojoc files, so run build-all after changing a package's sources — including for the editor, or the LSP reports phantom unresolved imports. Copy .vscode/settings.example.json to .vscode/settings.json and fill in your absolute path.

To probe an upcoming Mojo nightly without changing the pin:

uv run poe nightly-try           # swap the venv onto the latest nightly
uv run --no-sync poe test-all    # --no-sync is REQUIRED while on a nightly
uv run poe nightly-restore       # back to the pinned stable toolchain

A plain uv run re-syncs the venv to uv.lock and silently reverts the nightly, which would make the check a no-op.

License and attribution

MIT — see LICENSE.

packages/m0-http/lightbug_http/ is a hard fork of MIT-licensed work by Valentin Erokhin. Upstream was archived and there is nothing to rebase onto or send patches to; this copy is maintained here. NOTICE records the provenance and every category of modification, and PROVENANCE.md explains how this repository was extracted from a private monorepo.