# 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](https://m0serve.dev/docs/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/`](https://github.com/codetalcott/mojo-http/tree/main/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`](https://github.com/codetalcott/mojo-http/blob/main/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`](https://github.com/codetalcott/mojo-http/tree/main/apps/django_wsgi/) and [`apps/flask_wsgi`](https://github.com/codetalcott/mojo-http/tree/main/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](https://m0serve.dev/notes/wsgi-vs-asgi-history.md) 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.