m0serve Realtime from a synchronous Python app

WSGI conformance

Where the WSGI implementation stands against PEP 3333, clause by clause, and how the conformance is checked.

m0-wsgi hosts WSGI applications. Django is one, and the package has no opinion about which one you run — grep -i django packages/m0-wsgi/**/*.mojo returns nothing. What is Django-shaped is the evidence: apps/django_wsgi is the only WSGI example, smoke-django is the only proof the bridge works, and the example handler is named DjangoHandler. This document is about closing that gap without deepening it.

Tier 1 — PEP 3333 conformance (done)

wsgiref.validate is the stdlib's own WSGI checker and the closest thing that exists to a conformance suite for a server. poe smoke-django runs a pass with M0_WSGI_VALIDATE=1, which wraps the application in it, plus a /pep3333/canary route that proves the wrapper is engaged. See the "PEP 3333 conformance" section of ROADMAP.md.

It rides on the Django example today only because that is the WSGI example that exists. It should move to the bare app below.

Tier 2 — do not vendor Django's test tree

The earlier plan was to repoint tests/servers/tests.py (21 tests, real HTTP over a socket) at this server via LiveServerTestCase.server_thread_class. That is mechanically easy and it is the wrong trade.

Auditing the 12 tests that would pass, by what the assertion actually targets:

Target Tests
The wire — our server test_protocol, test_environ, test_keep_alive_connection_clears_previous_request_data, test_view/test_404 (trivially)
Django's staticfiles handler test_static_files, test_no_collectstatic_emulation, test_media_files
Django's ORM across processes test_fixtures_loaded, test_database_writes
The harness's own port allocation test_port_bind, test_specified_port_bind

The three static/media tests exercise the _StaticFilesHandler(_MediaFilesHandler(WSGIHandler())) stack Django builds for itself (testcases.py:1761), not anything this server does. So the real yield is four assertions, each a handful of lines against a plain WSGI callable — and the price is a pinned third-party source tree in CI (the wheel has no tests/; only the sdist does), an expectations file that must be re-read every time that pin moves, and a conformance story told in Django's vocabulary rather than PEP 3333's.

Not worth it. Harvest the ideas, not the tree: tests/servers/tests.py is worth reading once for assertions to port, and nothing more.

Tier 2, revised — a framework-neutral suite (built)

1. A bare WSGI example

apps/wsgi_bare/ — a plain PEP 3333 callable with zero third-party dependencies, 19 routes, each pinning one paragraph of the spec. It is served by the same m0serve binary as the Django and Flask rows — bin/m0serve bareapp.wsgi:application --app-dir apps/wsgi_bare — so the rows are Python-only and the diff between them is the point: there is none on the server side at all. It is the primary conformance target because a failure against it is unambiguously the server's: there is no framework in between to blame. It also gives CI a WSGI smoke test that runs whether or not Django is installed, and it makes the README's "any WSGI app" claim demonstrable rather than asserted.

2. Conformance assertions over it

poe smoke-wsgi, wired into CI ahead of smoke-django. It runs M0_WORKERS=2 throughout, then repeats a subset under M0_WSGI_VALIDATE=1.

It reaches PEP 3333 surface no framework-based test can, which is what makes it a better corpus than Django's suite rather than merely a cheaper one: the write() callable, a second start_response with and without exc_info, multi-chunk iterables, close() on the response iterable, arbitrary status passthrough, and wsgi.input read patterns (read(), readline(), iteration, reading past EOF).

Plus the four worth porting from Django: HTTP/1.1 on the wire, QUERY_STRING arriving still percent-encoded (the raw-vs-decoded distinction environ.mojo documents and nothing else pins), keep-alive not leaking one request's body into the next, and a re-entrant request.

What it found immediately

write() discarded every byte. The shim returned lambda data: None, so an application using the legacy write callable got a 200 with an empty body and no error anywhere. Django never calls write(), so no Django-based test could have found it at any level of effort — and neither could tier 1, because wsgiref.validate type-checks the call and not its effect. Fixed in bridge.mojo: start_response now returns a real appender, and the iterable is drained before the writes are joined, because an application may call write() from inside the generator it returned.

A second start_response without exc_info was silently accepted, last call winning. PEP 3333 makes it an application error; the shim now raises and the server answers 500. With exc_info the spec requires replacing the stored status and headers unless the headers have already gone out — which for a fully-buffering server is never, so replacing is always the correct branch. That part was already right by accident; it is now right on purpose and pinned.

urlopen from inside a view SIGKILLs the worker on macOS. Not a bug in this server, but a trap for anyone using it. urlopen consults the system proxy configuration through _scproxy, which calls into CoreFoundation; Objective-C refuses to run in a process forked without exec and aborts:

objc[13802]: +[NSNumber initialize] may have been in progress in another
thread when fork() was called. We cannot safely call it or ignore it in the
fork() child process. Crashing instead.

The supervisor respawns the worker, so the symptom is a dropped connection and a churning worker rather than an obvious crash. Confirmed fork-specific: M0_WORKERS=1 runs the identical code with zero objc lines in the log. Use http.client.HTTPConnection, which performs no proxy lookup — that is what /reentrant does, and its docstring says why. This is the same family as the exit_worker() rule already in CLAUDE.md: after fork() without exec, platform runtimes are off limits.

Single-worker re-entrancy deadlocks, as predicted. /reentrant on one worker blocks for the full 10s timeout and returns 504; on two workers it answers in ~14 ms. Django's equivalent test calls urlopen with no timeout and hangs forever — the difference between a red test and a CI job that runs until the runner kills it.

3. Frameworks as a matrix (Django + Flask)

smoke-wsgi asks whether the bridge implements PEP 3333, against a bare callable with no framework to blame. The rows ask a different question: whether a real framework's idioms survive the crossing — its router, its cookie jar, its body parsing, its error handling. Those need a framework, and every framework answers them identically, so the assertions live once in scripts/wsgi_framework_contract.sh rather than once per row:

GET / the row's own body, passed in as an argument
GET /cookies two Set-Cookie headers on one response
GET /cookies/echo request cookies, sorted k=v, joined with |
POST /echo the request body unchanged, and again at 256KB
GET /binary all 256 byte values
GET /query?name= the framework's own parameter parsing
GET /boom raises, so the 500 path is reachable
any unrouted path the framework's own 404

apps/django_wsgi and apps/flask_wsgi both implement it, and smoke-django and smoke-flask both call the same script. A row that needed assertions of its own would be evidence the host is not framework-agnostic, so the shared script is the test — not a convenience.

Flask required no changes to m0-wsgi: the app, a server.mojo identical to the other two apart from the module name and port, and a task that calls the contract. It passed on the first run. One framework is an anecdote; two that share a contract are a claim.

Django keeps its own extra assertions — signed-cookie sessions, the RSS leak guard, wsgi.multiprocess, the prefork parallelism check, the validator pass — because those genuinely are Django-specific or server-specific rather than framework-contract material.

Adding Pyramid or Bottle now costs an app directory and a task, not a CI dependency and not a line of the contract. Flask is a dev dependency and smoke-flask skips cleanly when it is absent.

What landed

apps/wsgi_bare/ — 19 routes, no third-party imports built
served by m0serve (no per-row server.mojo) built
poe smoke-wsgi, poe serve-wsgi-bare built
write() + double-start_response fixes in bridge.mojo fixed
CI step ahead of smoke-django, wsgi.log in failure artifacts wired
Shared scripts/wsgi_framework_contract.sh, with smoke-django refactored onto it built
Flask row: apps/flask_wsgi/, poe smoke-flask, poe serve-flask built

M0_WSGI_VALIDATE stayed on the Django example as well as moving onto the bare app. Removing it there would have dropped real coverage — Django is the realistic-traffic case — and it costs four lines to keep.

Warning ratchet unchanged at 68. No sdist fetch, no pinned source tree, no expectations file.

A note on "beyond Django"

The fork matters for how much of this is reusable:

  • Flask, Pyramid, Bottle, Werkzeug are WSGI. Same bridge, adapter only — they are matrix rows, and the suite above covers them the day it exists.
  • FastAPI, Starlette, Litestar are ASGI, which is a different protocol and a different package, not an adapter. (Dated record, kept as written; it has been superseded — the ASGI executor hosts them now, a Python event loop per serving loop coexisting with the Mojo one, and the design record §8 is the current account.) The current architecture is actively hostile to it: the handler runs synchronously on the event loop, the process is single-threaded, Mojo never acquires the GIL, and the fork must happen before the first Python call. ASGI wants a Python event loop coexisting with the Mojo one. That is a design problem to be taken on deliberately, not reached by extending m0-wsgi.

The HTTP-level assertions in the suite above are protocol-agnostic and would carry over to an ASGI host unchanged — which is a further argument for writing them against a bare callable now rather than against Django's test tree.