Roadmap
Milestones, known issues with what retires each, what is not planned, and the index of design notes.
The project's state. What the server does is SPEC.md, one row per capability with the gate that proves it. The milestones below are computed rather than remembered, and 1.0 has shipped, so what this reports now is whether the conditions it was taken on still hold:
uv run poe milestones
The reasoning behind the design is in the design notes.
Milestones
Three derive from row status in SPEC.md; no row carries a milestone of its own.
beta: no row is implemented, the sheet's word for "in the tree with no
gate dedicated to it". Gating an implemented row has found a real defect
four times out of four (A4, I16, L17, A11), so those rows are both the
finish line and the highest-yield work.
1.0: beta, plus every planned row outside section N built or moved
to out of scope with a reason, plus a soak against real applications
(REAL_APP_VALIDATION.md) no more than two minor
versions behind the tree, plus each known issue below naming what retires
it. Section N is left out because 1.0 shipped before the section existed;
its rows are the next milestone's.
the application layer: no section-N row implemented, every section-N
planned row built or moved to out of scope with a reason, and a soak on
the layer — an application outside apps/ running on Views or
Fragment, recorded in
REAL_APP_VALIDATION.md
under the same staleness rule. MET on 2026-10-02 by unotes, written
outside the tree by this repository's owner to exercise the layer: the
record says what that does and does not show, an independent author's
application among the second. Standing decisions about the layer are in
DECISIONS.md.
poe check-milestones gates the rot: every known issue carries a
Closed by: line naming SPEC rows or none, and an issue whose rows are
all verified fails the build until it is moved to Recently resolved. The
soak's staleness is reported rather than gated, because nobody can re-run
somebody else's Django projects inside the pull request that trips it.
Known issues
-
The asyncio executor cannot run on free-threaded CPython. Mojo's stdlib lays
PyObjectout for the GIL build (measured on 1.0.0 and not re-measured since the pin moved to 1.1.0; the weeklypy-canaryrun is what answers it), so the executor's in-process Python type (ExecutorPort) segfaults on a free-threaded build (modular/modular#5726). The server refuses instead of crashing: an ASGI application on such a build exits 78,--doctorreports the same check, and under--workersthe supervisor passes the 78 up. So an ASGI application cannot run under--threadson this toolchain. WSGI is unaffected.Closed by: none — an upstream fix to the
PyObjectlayout retires it; the re-test issmoke-django-realtimephase 6 on 3.14t, with L18 keeping the refusal honest until then. Retiring it also means turningsmoke-mounts-threads' pinned ASGI refusal back into a served ASGI mount beside the WSGI ones (SPEC M23), the mixed phasesmoke-hybridcarried as phase 3t until 2026-09-14. -
The Linux wheel misses RHEL 9 by one glibc minor. The wheel is tagged
manylinux_2_35, which covers Ubuntu 22.04 and Debian 12 but not RHEL 9 at 2.34;pipdeclines it rather than installing one that crashes. The floor is set by the Mojo runtime the wheel bundles, not by the build host: measured on the 1.11.0 wheels,_bin/m0serveitself needs onlyGLIBC_2.34,libAsyncRTRuntimeGlobals.soneeds__rseq_size@GLIBC_2.35andlibKGENCompilerRTShared.soneedsstd::condition_variable::wait@GLIBCXX_3.4.30, GCC 12's libstdc++, which RHEL 9 does not ship. So building inside amanylinux_2_34container, the closer this issue once named, would neither lower the tag nor make the wheel run there — and the pinned compiler itself fails to start in that container on the same symbol (the measurement).Closed by: none — the toolchain's runtime. It retires itself the release whose wheel's
libAsyncRTRuntimeGlobals.soneeds nothing aboveGLIBC_2.34and whoselibKGENCompilerRTShared.sonothing aboveGLIBCXX_3.4.29:wheel_tag.pythen measuresmanylinux_2_34on its own and names the file that set it in the build log, and the release's consume job installs on a 2.34 host. -
Under
--workers, which worker wins an accept is CPU placement, not load. Two workers sharing one listener: the worker on the client's own CPU loses every accept race, measured 80 of 80 with the probe pinned beside it. Accept sharing (E16) hands connections to the least-loaded sibling, so a deployment sees the hand-off rather than the race, andsmoke-reload's two-worker phase asserts failover with SIGSTOP rather than fairness. Per-workerSO_REUSEPORTlisteners balance in every placement and are not adopted, because a connection queued at a worker that dies is reset until the respawn rebinds. The measurements are in the note.Closed by: none — outside the server's own behaviour.
-
Beside an installed MAX, the parallel runtime is loaded where nothing names it: in every macOS build, and under
mojo runon Linux too.mojo buildon macOS linkslibAsyncRTMojoBindingsinto every binary oncemax-coresits beside the compiler, whether or not the source importsmax.algorithm; on Linux it links it only where named. Measured on CI (2026-09-25): the same demo-mountbin/m0servebundled three runtime files in the MAX-freeapple-siliconjob and four afteruv sync --group max.mojo runbuilds no binary: the program runs inside the compiler's process, which maps the runtime oncemax-coreis installed, so thereparallel_runtime_linked()answers true for any source — measured on Linux on 2026-09-26, where the same source built answers false (macOS's builds read as linked already). The refusals of E32 (the Mojo host,M0_WORKERSabove 1) and E33 (m0serve,--workers Nand--reload) read the loaded images, so on such a machine they fire for a binary, or amojo run, that never callsparallelize.M0_THREADSserves the host's case and--spawn-workersm0serve's; the shippedm0servewheel is built without MAX and is not affected.What a contributor with the
maxgroup synced meets: everymojo runof a host application above one worker exits 78 —smoke-shutdown,smoke-counterandsmoke-sim-loopeach start one at two workers, and pass with the group unsynced, as CI runs them (it syncs the group for its two MAX steps alone). The unit tests do not depend on the venv:test_host.mojoandtest_host_flags.mojosupply the fact wherever a verdict is about something else, and follow it where they gather it —test_host.mojoassumed it absent until the 2026-09-26 pre-release run, wheresabotage-host's baseline failed on two tests with the group that itsparallelarm needs.Closed by: none — a toolchain that links the runtime only where it is named and a
mojo runthat maps it only for a program that imports it, or a fact that can tell a loaded runtime from a used one, retires it.smoke-serve-parallel-runtime's control phase reads the binary's own load commands, so the day the link disappears its macOS line changes from "refused at two workers" to "passes"; themojo runhalf is re-tested by syncing the group andmojo running a program that printsparallel_runtime_linked(). -
m0-postgres reads
timestamp,timestamptz,byteaandfloat4differently in binary mode.binary=Trueis one choice for the whole query, and for these types it changes what the readers return. Measured on 2026-09-28 against Postgres 17, the session in America/New_York:timestampandtimestamptz: text mode'stext()is the server's rendering (2026-09-12 12:00:00, and for atimestamptzthe session's zone,2026-09-12 08:00:00-04); binarytext()is the count of microseconds from 2000-01-01 (842529600000000), and binaryint()answers that count where text mode raises.bytea: text mode'stext()is the escape\x0080ffandbytes()that escape's ASCII; binarybytes()is the raw bytes, and binarytext()aStringholding them unchecked, so not necessarily UTF-8 (the hazard SPEC G14 describes).float4: binary widens to a double, sotext()andfloat()read0.10000000149011612where text mode reads0.1.float8, andfloat4with it: binarytext()is Mojo's notation, not the server'sfloat8outorfloat4out:100000.0,-0.0,infandnanwhere text mode has100000,-0,InfinityandNaN.
Past the notation, a number can differ too, because Mojo 1.1's
Float64printing and parsing are not correctly rounded (the next entry): binarytext()of6.0146505155939864e16is6.014650515593986e+16, a neighbouring double, and text mode'sfloat()can differ from binaryfloat(), which decodes exactly.Float32printing has the same defect, which is whyfloat4is not rendered through it. SPEC O11 claims only the types that agree.Closed by: none — a design round retires it: a calendar and the session's
TimeZonefor timestamps,float4outandfloat8out's notation and a correctly rounded printer and parser for floats, andbytea_outputforbytea. An application that reads these types in binary mode is what would schedule it; until then, read them in text mode. -
Mojo 1.1's
Float64printing and parsing are not correctly rounded. Every miss is one unit in the last place, in both directions. Printing: stepping through the bit patterns of every exponent, 23 of 75,388 doubles printed as text naming a neighbouring double, all between about 2e16 and 6e19, where about one random double in twenty misses;String()of2.0502092240948628e16is2.050209224094863e+16. Parsing: the shortest forms of 47 of 60,000 random doubles parsed to a neighbour, at magnitudes from 1e-295 to 1e22, and 55 of another 60,000, most between 1e16 and 1e22 and a few from 1e-297 to 1e276;Float64("2.3914322253667574e+17")is one below the double it names. Measured on Mojo 1.1.0 against CPython's correctly roundedfloat()andrepr().Two readers here parse with it. m0-postgres's text-mode
float()parses the server's shortest form, so it and binaryfloat(), which decodes the bits exactly, answered different doubles for 7 of 3,000 randomfloat8rows in one run and 2 of 3,000 in another. m0-core'sparse_json_numberreads a JSON number the same way, one below on2.3914322253667574e+17. Printing matters where printed text is read back as a number, as binarytext()of afloat8is (the entry above).Closed by: none — an upstream defect. A Mojo release whose conversions round correctly retires it, and
scripts/probes/float64_rounding_probe.mojois the re-test after a toolchain bump: it exits 1 while its known misses still miss, and a clean exit is the cue to sweep again. If an application needs exact round trips before then, an in-tree correctly rounded parser and printer would retire it for the readers here.
Planned
A planned row in SPEC.md names a heading here, and the checker
fails if it does not resolve. Nothing is planned: the server's last two
rows (L25 and L26, from serving FastHTML's own examples beside uvicorn)
shipped on 2026-09-23, and the application layer's last (N13, sessions and
CSRF behind a login) on 2026-09-12. A row added here names the application
that pulls it, the gate that will verify it, and the decision
it retires, before it is built.
What stands between the application layer and its milestone is no longer
a row but the soak: an application outside apps/ running on Views and
Fragment, recorded in REAL_APP_VALIDATION.md.
The last entries, all built:
- A login on the notes app — shipped 2026-09-12
- A Datastar form, end to end — shipped 2026-09-12
- An SSE hold from a Mojo mount — shipped 2026-09-11
- Accept sharing: workers sharing a listener share its connections
- 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. The number that frames each: the
Mojo HTTP layer alone does 229.4k rps/core on
hello, the executor does 85.0k, uvicorn with
uvloop does 85.7k and uvicorn --loop asyncio
does 57.8k. Everything between the first two
figures is Python-side per-request work and the loop-to-executor handoff, so
optimising the HTTP 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/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 over 15% of the layer's time.mojo-framework/packages/m0-datahas an SoA arena to start from. - Automatic
Varytracking and dynamic compression. Negotiation coversAccept; theAccept-EncodingandAccept-Languagenegotiators nothing called were removed on 2026-09-29 (DECISIONS D54). The framework ships no compressor.
Recently resolved
- The handler pool's wake and thread records may share a cache line on Apple silicon (each lane's wake words and each thread's record on a 64-byte stride, in blocks
mallocaligns to 16, where an M4's lines are 128-byte) — resolved 2026-10-09 by measurement (fork review LF24): a 128-byte stride with 128-aligned blocks, alternated with the 64-byte stride over five rounds on a quiet M4, was faster in no cell — pooled p50 0.116 ms against 0.125 at slow 1 and 0.124 against 0.123 at slow 2, the ranges overlapping, and 0.658 ms a request in the fair arm for both. The strides stay; the three docstrings that called each record a cache line of its own now say what it is. The artifact isbench/results/pool-ab-2026-10/pool-ab-LF24-20261009T042627Z.json; the runbook isscripts/probes/quiet-machine-ab.md. - A pool thread's park cost one more system call, unmeasured (since LF22's fix, SPEC I39, a thread parking on its own channel makes one non-blocking
recvon its lane socket after announcing the park) — resolved 2026-10-09 by measurement: an arm without that look, alternated with it over five rounds on a quiet M4, differed by about 2 µs a request in opposite directions — pooled p50 0.126 ms with the look against 0.124 without at slow 1, 0.124 against 0.127 at slow 2, under 2 % of either median and the ranges overlapping — and not at all in the fair arm (0.658 ms). The look costs nothing measurable and stays. The artifact isbench/results/pool-ab-2026-10/pool-ab-LF22-20261009T042937Z.json. - A WSGI hold replayed nothing on reconnect (
M0-Hold: streamsubscribed to a registry whoseLast-Event-IDhandling was the redelivery filter alone, so every application kept a poll beside the stream) — resolved 2026-10-06 (I33, D65): each loop journals the last--replay-framespublished frames and a reconnecting hold is caught up from it, all or nothing, with one unnumberedm0-gapevent where the journal cannot supply what was missed, and an id from a previous incarnation clamped rather than left to starve the client. The write-up is A hold that replays — 2026-10-06. - Two ASGI loading and scope differences from uvicorn (a request whose chunked body the loop decoded reached the application with both
transfer-encoding: chunkedand acontent-length, and a GET with acontent-length: 0and aconnection: keep-aliveits client never sent; a module callingasyncio.create_taskat import, FastHTML's first official example, failed to load with a bareRuntimeError: no running event loop) — resolved 2026-09-23 by L25 and L26. A parsed request carries the headers its client sent: the outgoing constructor it used to be built through no longer fills in a length, aConnectionand aHost, and a de-chunked body is described by its length with itschunkedcoding removed. The WSGI environ follows, mapping the same headers. The import is refused by name, with the fix, a lifespan startup handler, from the server,--doctorand discovery alike; uvicorn refuses the same module without--reload. Both conformance steps read the scope and the environ back, and the ASGI step loads the module. - The gateway rewrote parts of an application's response head (a redirect, a 204 and FastHTML's default 404 page went out as
application/octet-stream, aFileResponseHEAD ascontent-length: 0, and every 204 and 304 withcontent-length: 0, a native one included) — resolved 2026-09-23 by A21 and K12: the gateway relays the head as sent, adding only the framing that is the server's — a buffered body's measured length, and on a HEAD the application's own — and the event loop drops a length and a body from every 1xx and 204, whoever set them, keeping only a 304's own length. Found beside them and fixed with them (L27): a HEAD to a streaming ASGI route was streamed like a GET, and the loop wrote the whole body after the head, 10,000 of 10,000 bytes, where a keep-alive client reads its next response; a HEAD to a hold or a native SSE route was held as the stream a GET opens, the same way.scripts/head_probe.pyreads each head in both conformance steps, and a request after it on the same connection. - A process the application started inherited the server's sockets (FastHTML's terminal example took 10 s to close a WebSocket, because the shell it had started held the connection) — resolved 2026-09-23 by G16: every descriptor the server creates is close-on-exec, atomically on Linux and by a second call straight after on macOS; the spawned-worker hand-off keeps exactly the descriptors the new image adopts across its own exec, where it used to keep them across every exec in every mode; and
m0pubwrites only to a bus it can see, never into a child's own file on an inherited number.smoke-exec-inheritrequires a child started withclose_fds=Falseto hold nothing of the server's in six shapes. mojo buildneeds a C compiler on Linux and nothing said so (apython:*-slimimage failed withunable to find suitable c compiler for linking, after the whole compile) — resolved 2026-09-20 for an application built with them0CLI (N25):m0 buildandm0 doctorcheck before running the compiler and name the fix,apt-get install build-essentialor the platform's own. The check is not the one first planned, on two measured counts: mojo 1.1.0 looks for the literal nameccand nothing else, so a machine withgccand noccis refused by name, and accthat exists and cannot link (gcc withoutlibc6-dev) is caught by linking a one-line program. Inside this repository nothing changes —mojo buildby hand still says what it said, anddeploy/mojo/Dockerfileinstallsbuild-essential. The write-up is The m0 wheel: source, an exact pair, and a CLI that refuses — 2026-09-20.- Mojo 1.0's
PythonObjectinterop leaked a reference per call argument and per__setitem__value — resolved 2026-09-18 by moving the pin to Mojo 1.1.0, which carries the upstream fix (modular/modular#6833). Measured in this tree against 1.0.0 as the null case: +1001 references per 1000 operations before, 0 after. The bridge keeps its raw C API environ build, which was always the faster path as well as the safe one, so nothing about the request path changes; what changes is that a per-requestPythonObjectargument is now a performance preference rather than a correctness constraint. The write-up is The pin moves to Mojo 1.1.0 — 2026-09-18. - The Mojo host's drain and its producer join ran in sequence — resolved 2026-09-17 with the pool lane (E26, D31): the loop stamps the producer's stop word as its drain begins and both joins count from that stamp, so a request in flight at SIGTERM beside a step past its bound leaves inside one bound. The premise as first recorded was wrong in one detail — the drain ends a HELD stream at once, so what stretches it is a request in flight, not a stream — and the gate holds a request of a few seconds instead. The write-up is The ramp test: lanes in the host, one module on two hosts — 2026-09-17.
- Sessions and CSRF behind a login (N13) — built 2026-09-12: a stateless signed cookie and a CSRF token derived from its tag, on
apps/fragment_noteswith one user;m0_http.sessionbesidegrant.mojo, forged and admitted on the wire by a CPython issuer, five rules sabotage-proven. D15 retired; D24 and D25 record what it deliberately is not. The write-up is A login on the notes app — shipped 2026-09-12. - A Datastar form, end to end (N12) — built 2026-09-12: the todo demo's rename form,
form(req)on the other side, a smoke on the wire and a Chromium run that recorded what the pinned bundle sends; D21 confirmed. The write-up is A Datastar form, end to end — shipped 2026-09-12. - Prefork workers did not share a listener's connections (the same worker won 32 of 32 on macOS and 23–31 of 32 on Linux, so
--workers 2served a keep-alive load at one worker's throughput) — resolved by E16, the accept-sharing hand-off; the write-up is Accept sharing — shipped 2026-09-05. - 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.
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
- Where the loop inversion wins, on a constrained box — 2026-09-08
- Do the benchmark page's conclusions hold on Linux? — 2026-09-08
- The outbox sweep — taken, scoped (2026-08-29)
- Pacing the pump's loop thread
- The Mojo handler pool — shipped 2026-08-28
- The detached loop — shipped 2026-09-03
- The executor's per-request Python work, and the C-API head read — shipped 2026-09-04
- Accept sharing: workers sharing a listener share its connections — shipped 2026-09-05
- Scheduling stickiness: which worker wins the accept race is CPU placement, not load
- Mojo language capabilities, surveyed 2026-08-28
- Considered, not built: routes that carry a function
- Periodic work off the event loop — shipped 2026-09-14
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
- Mojo 1.0's PythonObject leak, and what the pin bump will hit — answered by the bump
- The pin moves to Mojo 1.1.0 — 2026-09-18
- The host leaves the fork — 2026-09-18
- The page shell becomes a trait — 2026-09-18
- A vocabulary an application defines — 2026-09-18
- Loops on threads — 2026-09-18
- Threads first for m0 applications — 2026-09-25
- The demo in its own image — 2026-09-18
- Flags and a doctor for the host — 2026-09-19
- The m0 wheel: source, an exact pair, and a CLI that refuses — 2026-09-20
- The scaffold: two templates that compile where they lie — 2026-09-20
m0 dev,m0 image, and a release nobody has run — 2026-09-20- The Mojo stack's pages, and a front door CI walks through — 2026-09-20
- The scaffold's upgrade path — 2026-09-27
- A login in the layer — 2026-09-27
- A resource over a table — 2026-10-02
- A database that remembers what changed — 2026-10-02
- What the agent runs asked for — 2026-10-07
- MiniLM on the Neural Engine, served — measured 2026-09-04
- Inbound WebSocket flow control — shipped 2026-08-31
- The drain does not read a request body in flight — resolved
Post-mortems