Roadmap
The project's state on one page: the beta and 1.0 definitions, every known issue with what would retire it, what is deliberately not planned, and the index of design notes behind the decisions.
This page is the project's state, kept short on purpose. What the server can do is SPEC.md, one row per capability with the gate that proves it. What remains before 1.0 is computed, not written down:
uv run poe milestones
The reasoning behind the design -- what was built and measured, what was refused and why, the post-mortems -- is the engineering record, kept as design notes so this page can stay readable.
Computed, not remembered. poe milestones derives these from
SPEC.md; CI prints the report on every pull request. Before this
existed the direction of the work lived in one person's head and each
session reconstructed it — and the first thing computing it revealed was that
1.0 is nine rows away, which nobody had noticed.
Milestones derive from row STATUS rather than a per-row annotation. A milestone column would mean editing 149 rows and keeping them right for ever; these two definitions need no new data at all.
beta — nothing in the tree ships without a gate
Every row is verified, planned or out of scope. In other words no row
is implemented, which is this sheet's word for "it is in the tree and no
gate is dedicated to it".
The ordering is evidence, not taste. Gating an implemented row has found a
real defect four times out of four: A4's close linger re-arming every
pass (a slot held for the life of the process), I16's close codes echoed
rather than validated, L17's inbound WebSocket messages dropped 2932 of
3000, and A11's Expect: 100-continue failing on both case and HTTP/1.0. So
the remaining implemented rows are simultaneously the finish line and the
highest-yield work available.
1.0 — beta, plus the planned rows resolved, plus a soak
-
Every row
verifiedorout of scope. Aplannedrow is resolved by being built OR by being moved toout of scopewith a reason — deciding not to do something is a resolution, and the sheet already records refusals as first-class. -
A soak against real applications. REAL_APP_VALIDATION.md is the instrument and it has earned the requirement: run once against three Django projects nobody here wrote, it found four defects, three of which no in-repo app could have shown. Every application in
apps/was written to test this server, which is the right shape for a smoke suite and the wrong shape for "would this serve my application?". The soak is stale when it lags the tree by more than two minor versions.Its staleness is REPORTED and not gated, deliberately: nobody can re-run somebody else's Django projects inside the pull request that trips a gate, and a gate nobody can satisfy is a gate somebody disables. What is gated is the record being readable at all.
-
Known issues curated — each declaring what would retire it (below).
The rot rules, which are gated
poe check-milestones, sabotage-proven like every other checker here.
Every entry under Known issues carries a Closed by:
line naming the SPEC rows whose verified would retire it, or none for
one no row can reach — an upstream bug, a toolchain gap, a platform floor.
An issue whose rows are all verified fails the build until it is moved to
Recently resolved.
That rule earned its place the day it was written. "Suspected race: the WebSocket close path can RST instead of FIN" had been diagnosed and fixed in v0.15.1 and gated by L15 and L16 — and was still listed as an open risk a release later, because nothing retired it and nothing could. Anyone reading the page would have believed the close path was unreliable.
Known issues
-
The asyncio executor cannot run on free-threaded CPython. The executor's
ExecutorPortis a Python type built in-process withPythonModuleBuilder, and Mojo 1.0's stdlib laysPyObjectout for the GIL build (a 16-byte header; a free-threaded build's is 32,ob_tidand the biased refcount ahead ofob_type), soPyModule_Createreads the module definition at the wrong offsets and segfaults inPyUnicode_FromString-- the 2026-09-02 py-canary failure, identical to the trace in modular/modular#5726 (open since January, still present in 26.1). It is the build's layout, not the GIL's state:PYTHON_GIL=1does not help. Every WSGI path is fine there, because it only calls C functions on opaque pointers. Since 2026-09-02 the server REFUSES rather than crashes: an ASGI application on a free-threaded build exits 78 naming the issue (asgi_free_threading_refusal),--doctorreports the same check, and under--workersthe supervisor passes a worker's 78 up as the refusal it is instead of respawning it (E10). Consequence worth stating: an ASGI application cannot run under--threadson this toolchain at all, since that mode requires the build the executor cannot use. The pre-port pump (arun_until_completeper pass) would work there and could be restored behind the probe if anyone needs ASGI on 3.14t before upstream moves.Closed by: none — an upstream fix to the stdlib's
PyObjectlayout (modular/modular#5726) retires it; the re-test issmoke-django-realtimephase 6 on 3.14t, which must then run the full mixed server (L18 keeps the refusal honest until then). -
mojo buildneeds a C compiler on Linux and nothing says so. It shells out for linking, so a minimal image (python:*-slimcarries no compiler) fails withunable to find suitable c compiler for linking. CI never noticed because GitHub runners ship gcc.build-essential— or any cc — belongs besidelibsqlite3-devandpatchelf.Closed by: none — no SPEC row can retire this; it is outside the server's own behaviour.
-
The Linux wheel misses RHEL 9 by one glibc minor. Measured on CI, the binary requires glibc 2.35 and
scripts/wheel_tag.pytags itmanylinux_2_35_x86_64— which covers Ubuntu 22.04 and Debian 12 but not RHEL 9 and its rebuilds, which sit at 2.34. Worth noting the floor did NOT come from the build image (that runner is glibc 2.39): it is what the Mojo toolchain's own output requires, so building on an older image would not move it — confirmed independently on aarch64, where a Debian 13 container with glibc 2.41 also produced amanylinux_2_35wheel. Same floor, two architectures, two very different host glibcs. Reaching 2.34 means building inside amanylinux_2_34container. Deferred: it adds a container build to the release path for reach the first quiet 0.x does not need, andpipdeclines the wheel cleanly rather than installing something that crashes. -
Negotiation covers
Accept,Accept-Encoding(negotiate_encoding— codec-agnostic, for callers with precompressed variants; the framework deliberately ships no compressor), andAccept-Language(negotiate_language— RFC 4647 matching, serve-something-over-406 per RFC 9110's advice). What remains deliberate: no automaticVarytracking (the notes example sets it by hand) and no dynamic compression.Closed by: none — no SPEC row can retire this; it is outside the server's own behaviour.
-
Mojo 1.0's
PythonObjectinterop leaks a reference per call argument and per__setitem__value. Upstream toolchain bug, measured directly (a dict passed to a no-op Python function 1000 times gains 1000 references).m0-wsgiworks around it by never letting a per-request Python object cross through those operations. The environ is built through the raw C API instead —PyDict_SetItem,PyTuple_SetItem,PyObject_CallObject, which refcount explicitly — and the request body through a persistent Python-side bytearray, because no C-APIbytesbinding exists (seebridge.mojo). Any new bridge code must hold the same line, and the workaround can be retired if a future toolchain fixes the leak (re-test withsmoke-django's RSS guard, which must stay at 0 KB over 10k requests).The fix has landed upstream, and 1.0.0 predates it. It is modular/modular issue #6833, fixed by commit
c9d5048575("[stdlib] Fix PythonObject refcount leaks"):__call__and__setitem__took aPy_NewRefof an already-ownedsteal_data()result, and the non-stealing setters were handed owned references never released. The fix was authored 2026-08-11, nine hours after the 1.0.0 wheel was uploaded, reached publicmainon 2026-08-13, and is in every nightly from1.1.0.dev2026081405on; no stable release carries it. Measured directly on 2026-09-02 (1000 operations each, refcounts read through zero-argument Python readers so the instrument cannot leak): on 1.0.0 a positional argument, a method-call argument, a__setitem__value and a__setattr__value each pin exactly one reference per operation, while a keyword argument, a zero-argument call's result,len(),String(py=)and a getitem key are clean; on1.1.0.dev2026090205every row is zero. Retiring the workaround when the pin moves is optional, not automatic — the raw C-API environ build is also the 14.9 µs → 3.5 µs path, so the leak rules stop being a correctness constraint but the C API stays for speed. The RSS guard remains the instrument either way, and on that nightly it reads 0 KB over 10k requests with the bridge unchanged.What the pin bump will hit, verified by building the tree on
1.1.0.dev2026090205in an isolated copy (the list first recorded here from the release notes was three items short and one item stale):Atomicis reparameterized on a value type (Atomic[DType.int64]→Atomic[Int64]; 10 sites inffi_exports.mojo,multiworker.mojoandtest_threads.mojo; neither spelling compiles on the other toolchain, so it waits for the bump),_CTimeSpec.tv_subsecis renamedtv_nsec(2 sites,static.mojoandreload.mojo; same, waits for the bump), andm0-core/run_benchmarks.mojoloses the compile-timeBench/Bencherclosure forms (33 errors;bench-coreis outsidebuild-allandtest-all). Three more removals were applied ahead of time because their replacements already compile on 1.0.0:InlineArray→Array,std.ffi._CPointer→OptionalPointer, andmemcpy→unsafe_memcpy(the last surfaces only atbuild-serve, sincebuild-http's precompile ofsrc/never reaches the fork files that used it).Pointer.mut_castwas listed here as deprecated at 2 sites; the tree only ever usedunsafe_mut_cast, which is the recommended spelling. With the two remaining renames applied,build-all, all 1011 Mojo tests, every Python-side check,build-appsandbuild-serveare green on that nightly, and unique warnings move from 68 to 84 — the new ones areunsafe_ptr→ptrdeprecations, and the 16 the baseline calls unfixable on 1.0.0 (allocwithout aLayout,ABI="C") persist.CompilationTarget.is_x86()also changes meaning from "has SSE4" to "is the x86 architecture" — which is the semanticsEPOLL_EVENT_WORDSinc/epoll.mojoalways wanted, since epoll's packed event layout is a fact about the architecture, not about SSE4; on the old meaning, a baseline x86-64 build without SSE4.1 would have read 16-byte events on a 12-byte ABI. Uncaught exceptions also move to stderr; every smoke that greps forTracebackcaptures2>&1logs, so none care.Two defects in the early-warning machinery, found the same day.
nightly-canary.ymlhad failed on all three of its scheduled runs (2026-08-18, 08-25, 09-01) on theAtomicbreak and never filed its issue:gh issue create --label nightly-breakagefailed because the label did not exist. It exists now. And a childuv runre-syncs the venv touv.lockeven under a parentuv run --no-sync(measured: the child printed Mojo 1.0.0 and the venv stayed there), so theuv run mojoinsidetrailer_sabotage.py— thesabotage-trailersstep oftest-all— would have swapped a canary back to stable mid-run and reported the next step's "precompiled file is newer than the compiler" as a nightly break, the momentbuild-allfirst passed on a nightly. The three sabotage scripts now run the venv's ownmojo, the sibling of the interpreter running them.Closed by: none — no SPEC row can retire this; it is outside the server's own behaviour.
-
Scheduling stickiness: two forked workers, one shared listener, and eighty accepts in a row to the same worker — reproduced, and it is CPU placement, not load. Seen once (2026-08-29, ubuntu CI runner, PR #168's first run):
smoke-reload's two-worker phase re-forked both workers onto the new module — both logged their loop start — and then every one of ten rounds of eight fresh connections was answered by worker 6522; the smoke wants to see both pids and failed. The first recorded failure of that step, and the CI re-run of the same job on the same head passed. The mechanism it looked like was right, the load theory attached to it was not, and the fix direction it named was backwards; all three measured 2026-09-02 in a Linux container (colima, 4 vCPU, the 0.16.0 aarch64 wheel,--workers 2, the smoke's own probe of 10 rounds x 8 sequential connections) withscripts/accept_placement.py.The mechanism:
M0_WORKERSforks afterlisten, so both workers share ONE listen socket, each registers itEPOLLIN|EPOLLETin its own epoll, and on a connection both wake and the first to reachaccept()drains the backlog until EAGAIN while the other gets EAGAIN and parks. Which one is first is the scheduler's, and on a quiet 4-CPU box it is already the same one nine times in ten: 63–77 of 80 to one worker across 15 unpinned runs, the smoke passing each time only because the minority worker surfaced in round 1–3. What makes it ten of ten is where the CLIENT runs. Workers pinned to CPUs 0 and 1 and the probe on CPU 1: 80 of 80 to the worker on CPU 0, no round with both pids, in four runs of five (the fifth 79/1). Probe on CPU 2: 70–76 of 80. The worker that shares the client's CPU loses every time — the accept-queue wakeup runs inside the client's ownconnect()on its CPU, the worker with an idle CPU of its own is running before the client has blocked, and the co-located worker finds an empty backlog when it finally runs. Nothing pins tasks on a CI runner, but wake-affine placement can hold exactly that shape for the five seconds the probe lasts, and that is the sighting. Load is not the mechanism and tends to CURE it: everything on one CPU alternates 45/35 (one runqueue, CFS's vruntime picks the worker that has run less), and hogs beside either worker move the split toward even, not away from it. Concurrent connections do not fix it either (the burst is drained by whichever worker wakes first; 1–3 rounds of 10 in most placements).EPOLLEXCLUSIVEis NOT the fix direction: in a pure-Python model of the accept path it sends 80 of 80 to one worker in every placement, quiet or loaded — it removes the very race that was giving the other worker its share. Per-workerSO_REUSEPORTlisteners (bound after the fork, the kernel hashing connections across them) balance 40/40 to 46/34 in every placement and are the only shape that does. Not adopted on one CI failure: it changes the accept path of every prefork deployment, and a connection queued at a worker that dies is reset until the respawn rebinds — the shared socket is what makes the supervisor's respawn and--reloadinvisible to clients. Sequential one-shot connections from a single client are the smoke's shape, not a deployment's; keep-alive connections spread over time, and gunicorn's and nginx's prefork share the property.The smoke is asserting scheduler fairness (memory: "assert blocking, not fairness"), and loosening it to one pid would hide what it exists to see. The assertion that does not depend on fairness was measured on the same wheel under the reproducing placement: SIGSTOP the worker that answered, and the same probe is answered 80 of 80 by the other worker, promptly (5.5 s for ten rounds, all of it the probe's own sleeps); SIGCONT it, and SIGTERM exits 0 with no
crashedorrespawnedline — the supervisor reaps withWNOHANGalone, so a stopped worker is neither a crash nor a respawn.smoke-reload's two-worker phase now asserts it that way — stop the worker that answered, the other must serve the new body 8 of 8, and both pids must be the ones the supervisor logged as re-forked — and was sabotaged in both layers before it counted: withkill -STOPmade a no-op it fails as "SIGSTOP did not take", and with_reloadaltered to leave the old worker 1 alive while logging it as re-forked (so only the stop layer can see it) it fails naming the old body that worker served.accept_placement.py serve --stop-winneris the same measurement bare.SO_REUSEPORTper worker stays the change to make to the server only if a deployment, not a probe, shows the imbalance mattering.Closed by: none — no SPEC row can retire this; it is outside the server's own behaviour.
Planned
Rows in SPEC.md marked planned name a heading here, and the
checker fails if one does not resolve. So this section is the whole list of
things that page promises: adding a planned row means writing down what it
means here first.
Nothing is planned today; everything the sheet promises is verified or
out of scope. The design notes for the last four things this section held,
all built since:
- A conformance-suite tier
- Structured CI results
- Traceability: stable ids, then declared coverage
- Proven once, unloaded: an inventory of the gates with that shape
Not planned, and why
Recorded so they are not re-proposed. Each was considered against the one
number that frames this server's Python-hosting product: the Mojo HTTP
layer alone does 116k rps/core on hello, the executor does 61k, uvicorn
with uvloop does 82k and uvicorn --loop asyncio does 58k. Everything
between 116k and 61k is Python-side per-request work and the
loop↔executor handoff — so optimising the 116k layer buys nothing here.
- io_uring as a third backend. Linux-only, a whole event-loop implementation to maintain beside kqueue and epoll, and it optimises the layer that is not the bottleneck.
- A SIMD timer wheel. The loop already does a 1 Hz O(1024) sweep with no heap; there is no timer cost to remove.
- SIMD request parsing. Done —
lightbug_http/parsing.mojo. - Native Mojo coroutines replacing asyncio Tasks. The application is Python; its awaits are asyncio's. Replacing the executor's task machinery would mean reimplementing asyncio, not avoiding it.
- Arenas / SoA allocation in the loop. Evidence-gated rather than
refused: profile
hellofirst and pursue only if allocation is >15% of the layer's time.mojo-framework/packages/m0-datahas an SoA arena to start from.
Recently resolved
The two longest write-ups are design notes:
- A request body still arriving at SIGTERM held the drain to its deadline — resolved; the write-up is A request body still arriving at SIGTERM held the drain to its deadline — resolved.
- The WebSocket close path RSTing instead of FINning — resolved v0.15.1; the write-up is The WebSocket close path RSTing instead of FINning — resolved v0.15.1.
Design notes
The engineering record: long-form, dated, kept as written. Each was a section of this page until 2026-09-03, when the page became state-only.
How it got here
Built, and how (the Django server work, in order)
- Hold on a pool thread: the refusal that keeps
--realtimeoff real applications - Streamed WSGI bodies — shipped 2026-08-27
- Hardening the streaming seam — shipped 2026-08-27
- The WebSocket send window — shipped 2026-08-28
- The loop inversion — in progress 2026-08-28
- The outbox sweep — taken, scoped (2026-08-29)
- Pacing the pump's loop thread
- The Mojo handler pool — shipped 2026-08-28
- Mojo language capabilities, surveyed 2026-08-28
- Considered, not built: routes that carry a function
The gates and the evidence
- A conformance-suite tier
- Structured CI results
- Traceability: stable ids, then declared coverage
- Proven once, unloaded: an inventory of the gates with that shape
Open questions, and questions since answered
- The desktop-Mac server, and what the wheel gives up to ship
- Inbound WebSocket flow control — shipped 2026-08-31
- The drain does not read a request body in flight — resolved
Post-mortems