Changelog
Changes to mojo-http and the m0serve wheel, by version.
Notable changes to mojo-http. Format follows
Keep a Changelog; versions follow
SemVer. From 1.0.0 the served contract does not break
in a minor release: m0serve's flags and environment variables, the
M0-Hold/M0-Channel response headers, and m0pub.publish().
[Unreleased]
1.15.0 — 2026-10-11
The fixes from a review of the WSGI and ASGI gateway, the SQLite and
PostgreSQL bindings, the m0 wheel and m0-core, and the two issues
reported against 1.14.0. A client could forge a header that a proxy sets:
a request header whose name holds an underscore reached a WSGI application
under the same HTTP_* key as its dashed spelling, so X_Forwarded_For
could overwrite the proxy's X-Forwarded-For. m0-sqlite handed SQLite a
closed connection's handle, which crashes the process on Debian's and
Ubuntu's libsqlite3. A SIGTERM no longer waits out its 5 s budget for a
keep-alive connection holding a stray CRLF (#611), and m0serve installed
from the wheel no longer aborts when its interpreter's sysconfig names a
library directory that is not there (#612). Two changes touch the served
contract (see Changed): a request header whose name holds an underscore is
dropped before it reaches a WSGI application, or an ASGI application
served through --blocking-threads; and --replay-frames or
M0_REPLAY_FRAMES above 4096 is refused at startup with exit 78. The m0
wheel ships the framework's source, so an application built with m0
takes the framework's fixes by rebuilding against m0 0.12.0; the template
fixes reach the projects m0 new writes from now on.
Changed
-
The README states only what no other page owns, and links the rest. Its m0serve reference (flags, discovery, the ready line, modes, mounts,
--doctor, limits, exit codes), its benchmark block, its history and its repeated statements of the contract, the platforms and TLS are links to the pages that own them; it went from 974 lines to 583 and from 23 unsourced figures to none. Four facts only the README held moved to RUNNING.md: whichlibpythonm0serve loads, how a mount's prefix reaches its application, which WSGI bodies stream, and what--doctor's JSON carries (the note). -
A request header whose name holds an underscore no longer reaches a WSGI application. CGI's mapping turns
-into_, soX_Forwarded_ForandX-Forwarded-Forboth becameHTTP_X_FORWARDED_FOR, and the environ kept whichever the request carried later: behind a proxy that forwards underscore names (HAProxy, Caddy and Traefik do; nginx drops them by default), a client could replace the address, scheme or identity header the proxy set, the CVE-2015-0219 class. Such a header is now dropped from the environ, as gunicorn has dropped it by default since 22.0 and as Django's runserver does. Dashed names pass as before,X-Forwarded-*included. An ASGI application under the executor still receives every name as sent; the buffered ASGI path (--blocking-threadson an ASGI application), whose scope is built from the environ, drops them with it (SPEC G29). -
--replay-frames(andM0_REPLAY_FRAMES) above 4096 is refused with exit 78. Under--realtimeeach loop keeps up to that many frames of up to 64 KB each, and a full journal shifts its entries on the loop thread for every publish, so a depth like 100000 pinned hundreds of megabytes and slowed every publish with nothing said. The refusal is a rule in the list--doctorreads, so the doctor exits 78 too, with or without--realtime. It is asked after the rules a flag combination can fail, so--pg-listenwithout--realtimebeside a deep journal is still the usage error's exit 2. 0 and every depth up to 4096 are accepted as before (SPEC I43).
Fixed
-
A keep-alive connection holding a stray CRLF or the start of its next request no longer holds the SIGTERM drain to its 5 s budget (#611). The drain closed a connection between requests only when its receive buffer was empty, and the buffer keeps an answered request's bytes whenever anything follows them, so one extra CRLF after a request, or a head still arriving, kept the process up 5.1 s where it otherwise exits in under 0.1 s, past a Fly machine's default
kill_timeout. A head that has not wholly arrived is a request not yet made: the drain now closes its connection as it closes an idle one, as uvicorn does, and waits only for requests whose head has arrived, a body still arriving among them (D9). When the budget does end a drain, it now prints one line saying what it leaves (SPEC D14, D15). -
m0servefrom the wheel no longer aborts when sysconfig'sLIBDIRis not on disk (#612). A relocatable interpreter can report the build machine'sLIBDIR(python-build-standalone's 3.12.7 says/install/lib), so the console shim named no libpython, and the binary's runtime, finding none either, aborted at its first Python call with a native stack dump and exit 133,--doctorincluded. The shim now also searchesLIBPLandsys.base_prefix'slib/, a search that holds every path the runtime's own lookup tries; where it still finds nothing it exits 78, namingMOJO_PYTHON_LIBRARY, before the binary runs. It no longer skips an interpreter that reportsPy_ENABLE_SHARED=0: a macOS framework build (python.org's installer, Apple's command-line tools) does, and keeps a library to name. A static build with no shared library, which used to abort the same way, now gets the 78 (SPEC M30). -
An ASGI application's
state["m0"].publishnumbers its frames asm0pubdoes, from 1. It took the shared counter's previous value where every other publisher (m0pub,--pg-listen, the Mojo side) takes that value plus one, so on a server where an ASGI application publishes beside one of them, two frames could carry one id: a hold that received the first dropped the second with nothing logged, and a reconnect the replay journal could have served whole was sentm0-gapinstead. A fresh server's first ASGI publish was id 0, which no first-visit subscriber receives (SPEC L12). -
A WebSocket hold that presents a
Last-Event-IDahead of the counter is no longer silent. Since 1.12.0 an SSE hold clamps an id this server never allocated, a previous incarnation's, to the head; a socket hold still subscribed at the id it presented, so every numbered frame was suppressed until the counter passed it, under a clean log. A socket hold is now clamped the same way, inline and from a pool thread alike, and is still not caught up. A browser cannot put the header on an upgrade, so this reached other clients only (SPEC I12). -
A
--max-bodyorM0_MAX_BODYtoo large for an integer is refused, not wrapped.17179869184g(2^64 bytes) wrapped to a 0-byte cap, so every request with a body got a 413, and9999999999gwrapped negative and left the 4 MB default standing with no message. The flag now exits 2, andM0_MAX_BODYis ignored out loud like any unreadableM0_*value, the 4 MB cap standing (SPEC C1). -
--doctorreportsreplay_framesas 0 without--realtime, where the server keeps no journal whatever--replay-framessays. It reported the flag's value, 64 by default. -
m0-sqlite: a
Connectionused afterclose(), or aStatementafterfinalize(), no longer crashes the process on Debian's libsqlite3.in_transaction,last_insert_rowid,changes,total_changes,busy_timeoutandregister_array_modulehanded SQLite the closed connection's NULL handle, which a library built withoutSQLITE_ENABLE_API_ARMORdereferences, so the process died of SIGSEGV on Debian 12's, the base ofdeploy/mojo/Dockerfile. Apple's and Homebrew's builds carry the armor and answered wrongly instead:in_transaction()was True after the close, andregister_array_module()leaked its module buffer. The four accessors now answer False or 0 on a closed connection without calling SQLite, andbusy_timeoutandregister_array_moduleraise "… on a closed connection", ascreate_functionalready did. A finalized statement's handle is NULL too, andclear_bindings()handed it to SQLite the same way, where Apple's library answered success and did nothing: it now raises "… on a finalized statement", and so doescolumn_name(), whose entry point checks for NULL only under the armor too (SPEC O29). -
m0-sqlite:
executeand theConnectionconstructors refuse a NUL byte instead of running what comes before it.sqlite3_execandsqlite3_open_v2read C strings, soexecute("DELETE FROM t\0 WHERE id = 5")emptied the table and reported success, and a path holding a NUL opened the path cut there, losing a suffix the caller had checked for. Both now raise before SQLite sees the text, with the byte's offset and(rc=21), soerror_codeanswersSQLITE_MISUSE, as a function name holding one already was (SPEC O30).preparewas not affected. -
m0-sqlite:
prune_stampsraises the error that stopped it. When SQLite had already cancelled the transaction (SQLITE_FULL,SQLITE_IOERR,SQLITE_NOMEM), the prune's own ROLLBACK raised "cannot rollback - no transaction is active" in its place, soerror_coderead 1 instead of the real code. It now rolls back only a transaction that is still open, and always raises the original error (SPEC O26). -
m0-postgres:
notifies()delivers every notification libpq already holds before it reports a lost connection. A burst of NOTIFYs followed by the server's close (a restart, a failover,pg_terminate_backend) was read off the socket in one go, and the next call raised on the close with the rest still queued;reset(), the recovery, then freed them, so all but the first were lost and nothing logged them.notifies()now takes from libpq's queue first, reads the socket only when the queue is empty, and raises only when nothing is left. The--pg-listenlistener drains until None and resets on a raise, so it now publishes the whole burst before it reconnects (SPEC O15). -
m0-postgres: a
reset()whose re-LISTENfails no longer forgets channels, or stops at the first.reset()emptied its list of channels before listening on them again, so when one re-LISTENraised (a reconnect that landed on a standby, a second drop), every channel not yet restored left the list, and each laterreset()restored fewer, silently. It now re-LISTENs every channel, collects the ones that fail and raises once at the end naming each, so a channel that cannot be restored leaves the others listening; the list is kept whole, so the nextreset()tries every channel again (SPEC O15). -
m0-postgres:
Result.intraises for a text-mode value past 64 bits instead of wrapping. Thesum()of anint8column isnumeric, and a total beyond 9223372036854775807 read as a plausible number (2^64 as 0) with no error. It now raises "… does not fit in a 64-bit integer"; -9223372036854775808 still parses (SPEC O31). -
parse_json_intno longer wraps an integer pastInt's range, andparse_json_boolno longer readstruexas true.{"n":18446744073709551617}came back as 1; it is nowNone, as is any value outside -9223372036854775808..9223372036854775807. A boolean literal must end at whitespace,,,},]or the end of the body, sotrueishandfalsehoodareNone. The m0 wheel shipsm0-core, so an m0 application takes the fix by rebuilding. -
m0command-line fixes (them0wheel, not m0serve):m0 doctorrun on abin/serverthat dies by a signal (anabort(), a crash beforeserve) no longer exits 250 or 245, outside the documented 0, 1, 2 and 78; it is exit 1, sayingbin/server --doctor died of signal N.m0 new m0andm0 new mojo(andmax-core,mojo-compiler) are refused with exit 2: the scaffold pins a requirement of that name, so the project would depend on itself anduv synccould never meet it.m0 devasks the toolchain checks again before each rebuild. Afteruv add --dev 'm0==X'moved the watchedpyproject.tomlit compiled the new framework with the oldmojo; now it prints the refusal and the old server keeps serving.m0 build --releaseon Linux checks forpatchelffirst (exit 78 namingapt-get install patchelf;m0 doctordoes not ask), where it used to fail after the whole compile;docs/MOJO_DEPLOY.mdnames it.- A failed
install_name_tool -add_rpathorcodesignin the release build (relocate.py,bundle_artifact.py) now fails it, and a binary that lacks a search path it was to carry is refused, instead of adist/serverthat cannot load its runtime. m0 new .accepts a directory holding only.git, as aftergit initorgh repo create --clone, and leaves it untouched.m0 imagetags with the project's name frompyproject.tomlrather than the directory's, so a checkout underShopno longer fails on docker's lowercase rule.
-
The
boardtemplate no longer builds a board frame bigger than a slot's 64 KB outbox (them0wheel). Fifty messages of 1500 bytes, each escaped, rendered to several times that, and a frame over the cap is refused for every slot: the poster got a 200 and no tab was updated, with nothing logged.render_boardnow renders the newest messages that fit a byte budget, so the frame always goes out. A full state frame fitting the outbox is now a line of the scaffold'sAGENTS.md. -
The
viewsandauthtemplates no longer split their list across pool threads under--blocking-threads(them0wheel). Their views ran on the pool, where each thread builds a state of its own, so an item created on one thread was missing from a list another answered. Every view that touches the list is nowon_loop=True, asboard's are. The scaffold's.gitignorealso lists the four files its smokes andm0 buildleave behind (.dist.next/,smoke.one,smoke.two,smoke.jar), and theviews,authandlivesmokes serve on127.0.0.1rather than every interface. All of this reaches the applicationsm0 newscaffolds from now on; one already scaffolded applies it by hand, since a template is written once. -
README's "SSE replay is journal-deep" limit still said a WSGI hold replays nothing, and that an application keeps its own catch-up. 1.12.0 made that false (SPEC I33): the bullet now says a hold is caught up from the per-loop
--replay-framesjournal and sent onem0-gapframe when it cannot be, as RUNNING.md and them0pubdocstring do. ItsDatastarStreamhalf said a client further behind than the journal resumes live; since SPEC I30 it is sent no history andcaught_up(slot)answers false, so the view resyncs it withsend_to. The same section gave the Mojo pin as 1.0; it is 1.1. -
Six more README claims the tree had moved past. A handler's optional hooks were "the empty defaults shown here" beside an example that shows none;
m0-http"uses three functions" ofm0-core, where it imports from ten files; m0serve's exit codes omitted 78, which the--doctorexample below them exits with; uploads were raised throughServerConfig.max_request_body_size, a field an m0serve user cannot reach, rather than--max-body; a bufferedFileResponsewas "the recorded follow-up", with no record anywhere; and "Two more things it does" listed three. RUNNING.md offered--threadsfrom 3.13t, which is a dead end; it is 3.14t.
1.14.0 — 2026-10-10
The fixes and the smaller code of a review of the framework and the fork
for quality. Two of its findings were defects no gate had seen. Under
--max-keepalive-requests, a streamed response served as a connection's
last request left the connection open, and the next stream on it was cut
off after its head; a stream now carries the cap to its end and says
Connection: close. Under --reload, a worker forked into an index the
supervisor had given up on could outlive its supervisor. A static mount
answers 304 to an If-None-Match that drops the weak W/, a Range
bound that is not plain digits is no range at all, and a stream whose
client has gone is closed by the send that fails rather than a pass of the
loop later. Nothing in the served contract changes. The rest is the code
made smaller: what was written twice is written once, names nothing read
are gone, and two fork names say what they are, so a Mojo application that
imported one of those changes an import (see Changed and Removed). The
m0 wheel ships the framework's source, so an application built with m0
takes these by rebuilding against m0 0.11.0.
Fixed
-
A reload no longer orphans a worker it forked into an index the supervisor had given up on. Under
--reload, a worker that crashed past its respawn budget left its index vacant and the polling supervisor counting one worker fewer; the next reload forked a replacement into that index with the rest, as it should (the edit may be the fix), but the count stood, so once the rest had gone the supervisor exited, with 1, and the replacement served on with nothing supervising it. The supervisor now counts what it supervises from the live pids after every reload, and a give-up a reload has undone no longer sets the exit. -
A stream frame whose write registration the kernel refuses closes its connection from every path. A frame the socket took only in part waits for writability; when that registration failed, the heartbeat and the outbox drain left the slot as it stood, half a frame sent and no event to finish it, until the idle deadline, if any, where the WebSocket reader closed it. One rule now: the slot closes and its handler is told, as for a client that is gone. The write-ready path's own retry closes on the same refusal.
-
The keep-alive cap lands on a stream as it lands on any response. Under
--max-keepalive-requests N, a chunked stream served as request N skipped the cap and ended kept-alive with the connection's count at N; the next stream on that connection skipped it too, and a second check in the loop closed the connection once that stream's head had landed: a chunked head and nothing after it, and an ordinary request there was served, one past the cap. Now the cap is applied before the head goes out, for a stream as for anything else, and a stream carries it to its end: the head saysConnection: close, the stream arrives whole, and the connection ends after it. The count check that cut the second stream is gone. A WebSocket upgrade on the cap request is as it was: its handshake cannot sayclose, and its connection ends with the socket. -
A static mount compares
If-None-Matchweakly (RFC 9110 §13.1.2).StaticFilestags each file with a weakETag(W/"…") and compared a client'sIf-None-Matchwith it as written, so a client or cache that sent the tag back without itsW/read the whole file with200where the RFC's weak comparison answers304.StaticFiles,etag_matchesandconditionalnow share one rule: the opaque tags are equal, whichever side is marked weak. An application that callsetag_matchesgets the weak comparison too. -
The
409a pool thread answers for a streaming response readsthread's. A Mojo handler that begins a stream on a pool thread (the Mojo host underM0_BLOCKING_THREADS, or a--mount PREFIX=mojomount) is refused with a JSON body that endednot this thread s; it endsnot this thread's.
Changed
-
A stream whose client has gone is closed by the send that fails. When a frame an application queued (an SSE event, a WebSocket message, a piece of a streamed body) failed to send for any reason but a full socket buffer, the connection waited to be reported writable and closed a pass of the event loop later. It now closes in that pass, and the handler is told then (
sse_slot_disconnected), as it already was when a heartbeat or a WebSocket pong found the client gone. -
A
Rangebound is digits alone, as RFC 9110 writes it.StaticFilesread a bound with Mojo'sInt(), which takes a sign and blanks around the number:bytes=+0-1andbytes= 0-1were served as ranges, andbytes=--5answered416. A bound that is not plain digits now makes the header no range at all, and the whole file is served with200. -
The fork's kqueue, epoll, pipe, socketpair and process wrappers raise
SysError, the error the socket wrappers raise, so the message the server prints when one fails reads as the socket calls' do:pipe() failed, errno: 24is nowpipe: Too many open files (errno 24). -
m0_httpexports what the hosts import.from m0_http importnamesEX_CONFIG,SharedAtomics,shared_store,parse_int,read_long_flag,is_long_flag,DOCTOR_OK,Reportandparse_env_int, which m0serve and the Mojo host reached into modules for. Ten names that nothing outside their module read are no longer re-exported from the package; import each from its module:STANDARD_VERBS(m0_http.html),LOGIN_KEY_MIN(m0_http.login),find_keyandGRANT_COOKIE_ENV(m0_http.grant),CSRF_MESSAGE_PREFIX,SESSION_SIG_CHARS,SESSION_SUBJECT_MAXandSESSION_VERSION(m0_http.session), andint_list_envandspawned_worker_index(m0_http.prefork). Nothing served changes. -
Sessions and grants share one key ring.
KeyRing(m0_http.grant, exported fromm0_http) holds the keys a signed token may carry;SessionKeysis its alias and is used as before, andGrantKeysholds one asring, so code that readGrantKeys.keysreadsGrantKeys.ring.keys. Nothing served changes. -
Two fork names say what they are. Nothing served changes; an application built with the
m0wheel that named one renames it: the request side'sURIParseError(lightbug_http.http.request, an arm ofRequestBuildError) isRequestURIError, no longer sharing a name withlightbug_http.uri.URIParseError, andOffloadPool.stream_active()isexecutor_active(), which is what it answers. -
Code
m0-httpcarried twice is written once. Nothing served changes beyond the entries above. In the fork: the response's span constructor andencode()go through the owning constructor andencode_into; the chunked decoder's byte moves are one_shift_down; the two WebSocket frame encoders share_frame_header; the backends'try_add_readandtry_add_write_oneshotareEventLoopBackenddefaults, so a backend an application writes may drop its copies; the macOS close-on-exec mark of a fresh descriptor ismark_fresh_cloexecat its seven sites; the RFC 9110 list walk underTransfer-Encoding,Connection,If-None-Match,Accept,Vary,Allowand the form check islightbug_http.strings'trim_owsandnext_list_member; the event loop's descriptor-to-slot lookup, its chunked-body decode and its "body is complete" tail are one function each, the heartbeat, the WebSocket reader's replies and the outbox drain send a stream frame through one, and its pass raises nothing; the offload channels' tags, little-endian codec and bounded send arelightbug_http.offload_wire, whichoffload.mojoimports back name for name; andProvisionPoolcounts withstd.bit. In the framework: the supervisor's exit accounting and the post-fork prologue, the integers the environment readers and theRangeparser read (parse_env_int), the HTTP token check (is_token_char), the outbox boundWSHubshares with SSE, and the Mojo host's two serve paths, which build the same pre-fork pieces and refuse in the same words.
Removed
- Fork and framework names nothing read. Nothing served changes. The
m0wheel ships the source, so an application built withm0that named one of these needs its own copy:HTTPRequest.server_is_tlsand its constructor argument;lightbug_http.websocket'sWS_CLOSE_NORMALandbase64_encode(the handshake encodes with the standard library'sstd.base64.b64encode);lightbug_http.loop.state.TIMER_HEADER, whose timer nothing armed;OffloadLoopState.slot_is_executor(askOffloadPool.slot_is_executor); theProvisionErrorarm oflightbug_http.server.ServerError, never raised;m0_http.sse'sformat_sse_heartbeat_bytes; andStaticFiles.matches. Theserver_is_tlsargument sat just beforeinvent_headersinHTTPRequest.__init__, so a caller that passed it by position now setsinvent_headerswith that value. No caller in the tree passesinvent_headersby position.
1.13.0 — 2026-10-09
The rest of the fixes from the review of the server's HTTP core, the
lightbug_http fork, whose security fixes shipped in 1.12.1. A client
pipelining a burst of requests could hold the event loop for more than a
second, stalling every other connection on it. On Linux a 100 Continue
could be cut short ahead of its response when the send buffer was full,
and a client still sending its headers at the header timeout read a reset
instead of its 408. Half-closed and pipelined connections are answered
in full, a stream open when the server stops ends cleanly, the memory a
large upload grew is reused by later connections, and requests the RFCs
call malformed are refused, a CONNECT with 501 among them. One change
touches the served contract: a port outside 1-65535, from --port or
M0_PORT, is refused at startup with exit 78, where M0_PORT=0 served on
a port the kernel chose and --port 0 exited 2 (see Changed). The fork's
unused code is gone, which changes nothing served but removes names a Mojo
application could import. The m0 wheel ships the framework's source, so
an application built with m0 takes these fixes by rebuilding against
m0 0.10.0.
Fixed
-
A request head is answered the same however its bytes arrive (SPEC B29, fork review LF66; A3's LF59 is its duplicate). A request sent after two empty lines was refused with 400 when it arrived in one read and served when a read ended between the empty lines: where the server looked for the end of the head depended on how much the read before had brought. It is refused either way now. Where a head ends and how its body is framed is decided in one place, which the request fuzzer that runs on every change holds to this, and to a chunked body read a piece at a time (SPEC B30).
-
A carriage return before a request's first line is refused (SPEC B29, fork review LF67). A request whose first line came after a lone carriage return (a CR with no line feed behind it) was served when it arrived in one piece, its method read from the byte after the CR, and refused when it arrived in two. RFC 9112 makes a lone CR invalid; it is answered 400 either way now. Empty lines before a request, each a CR and a line feed, are still skipped.
-
A client that half-closes is answered with
Connection: close, and its connection ends behind the answer (SPEC A27). A client that shuts down its write side after its request has sent its last one, but when the request did not itself ask for a close the answer saidkeep-alive, and on Linux the connection was then held until the idle timeout (for good with--idle-timeout 0) waiting for a request that could not come. Requests pipelined ahead of the last one are still answered first. -
A half-closed request with a long head is answered (SPEC A29). A client that sent a request whose headers were longer than 4 KB (a large cookie, a long token) and then shut down its write side could have its connection closed with no answer, the rest of its headers still unread. It is now read to its end and answered.
-
A client that pipelines and half-closes gets every answer when the first comes from a pool thread or the ASGI executor (SPEC A30). With
--blocking-threadsor an ASGI application, when that answer went out only in part and the client's half-close was seen in the same moment, the connection closed as soon as the answer finished, and the requests pipelined behind it were never answered. They are now, and the connection closes after the last. Answers the event loop runs itself were not affected. -
A streamed response honours
Connection: close(SPEC A28). An ASGI application's streamed body, or a WSGI application's streamed iterable on a pool thread, goes out chunked over HTTP/1.1, and when the request asked forConnection: closethe response still saidkeep-aliveand the connection stayed open after the body ended. It now sayscloseand closes once the body is complete, as an unstreamed response does. -
A stream open when the server is told to stop ends cleanly (SPEC D12, D13). The drain says goodbye to every open stream before closing it, and wrote that goodbye where nothing may be written: into a streamed ASGI or WSGI body, where a client's chunked parser failed on it; into a WebSocket that had already sent its Close, as a second one; and into the middle of a frame still going out. Those streams now close as they stand, and the client reads the end of the connection; an SSE stream or WebSocket the server writes itself still gets its goodbye.
-
The access log times a keep-alive request when the header timeout is off (SPEC F22). A server whose
ServerConfigsetheader_read_timeout = 0logged thedur_usof every request after a connection's first as the time since the machine booted, and recorded no latency for them on/__metrics. They now run from the request's first bytes, as they always did with the timeout on; a connection's first request is timed from its accept, as before. m0serve and the Mojo host keep the default of 10 s, and were not affected. -
A WebSocket the application has stopped reading stays stopped while its close completes (SPEC I41). When an application falls behind the messages a client sends, the server stops reading that socket until the application catches up. Once the server's Close went out it started reading again, so the messages queued for a slow application kept growing for as long as the client went on sending. The socket now waits for the client's Close without reading until the application resumes it, or until the two-second close linger ends it.
-
A
CONNECTrequest is answered 501 and its connection closed (SPEC B18). It reached the application, and one that answers every method answered it 200, which a proxy forwardingCONNECTreads as an open tunnel: whatever the client sent next would pass through it unparsed. This server implements no tunnel, so no application, on m0serve or the Mojo host, receivesCONNECTany more. -
A chunked request body in another transfer coding as well is answered 501 and its connection closed (SPEC B21).
Transfer-Encoding: gzip, chunkedwas de-chunked and the body handed to the application still gzipped. The server decodeschunkedonly, and now says so with 501, as RFC 9112 §6.1 asks. ATransfer-Encodingwhose last coding is notchunked(a lonegzip,chunked, gzip) is still 400, as RFC 9112 §6.3 requires. -
A request target that is not a URI the server serves is answered 400 (SPEC B19).
GET pandGET host:80reached the application as a path with no leading slash, and a target holding a raw byte above ASCII (/caf\xe9) as a path that is not UTF-8. A target now opens with/, is anhttporhttpsURI, or is*on anOPTIONSrequest; a non-ASCII character must be percent-encoded, as every browser does, and the escape still reaches the application undecoded. -
A server-wide
OPTIONS *reaches the application as*(SPEC B19). It arrived asOPTIONS /, so an application could not tell a question about the whole server from one about its root page. A Mojo application reads*asreq.uri.path; a WSGI application reads it asPATH_INFOand an ASGI one aspathandraw_path, as gunicorn and uvicorn hand it on. Under m0serve the root mount answers it; with no root mount it is answered 404, as any path no mount claims. The view table (Views) reads*as a path of one segment: a table with a one-segment parameter route such as/:sluganswersOPTIONS *as that route's preflight, 204 with itsAllow, and a route registered forOPTIONSthere runs with*as its parameter; a table with no such route answers 404. -
URI.parsereads a query that follows the host directly (SPEC A35). Exposed: an m0 application whose tests build a request withURI.parse("http://127.0.0.1?x=1"). The host was127.0.0.1?x=1and the request carried no query; with a port,http://127.0.0.1:80?x=1, the query was dropped. The path is/and the queryx=1, as forhttp://127.0.0.1/?x=1. A#right after the host ends it too:http://127.0.0.1#topis host127.0.0.1and path/, where the host was127.0.0.1#top. A request from the wire was never affected. -
URI.parserefuses a port that is not one (SPEC A36). Exposed: an m0 application that parses a URL withURI.parse, its tests' request builders among them.:99999was read as port 34463,:65536as port 0 and:8xas port 8; each now raises. An empty port (http://127.0.0.1:/x), which raised, is read as no port, the scheme's default, as RFC 3986 §3.2.3 allows. -
A cookie a Mojo view builds can no longer add attributes of its own (SPEC G2). Exposed: an m0 application that builds a
Cookiefrom request data and sets it withResponseCookieJar.set_cookie. Each field was written as given, soCookie("theme", "dark; Domain=evil.test")went out astheme=dark; Domain=evil.test, a cookie for another site. A built cookie whose name is not a token (a b,c=d, an empty name), or whose value,DomainorPathholds a;or a control byte, is now dropped from the response, silently, as a header holding a line break is: nothing is logged. Every other value is written as before, a space, a comma, quotes, a backslash or a byte above 0x7F included, so a base64 or JSON value goes out unchanged. ASet-Cookieline an application handsadd_raw, which is how every WSGI and ASGI application's cookies arrive, is still sent as given. -
A built cookie with no
Pathand one withPath=/are two cookies. Exposed: an m0 application that sets the same cookie name twice in one response throughResponseCookieJar.set_cookie, once without aPathand once withPath=/. The jar treated a missingPathas/and kept only the second. A browser gives a cookie with noPaththe request's directory (RFC 6265 §5.1.4), so it stores both. Both are now sent. An emptyPath, or one not starting with/, means the same as none. -
NotFound(path)no longer writes the path into its body. Exposed: an m0 application that answers withlightbug_http'sNotFound, as the WebSocket examples do. The body waspath <path> not found, the request's own bytes, markup included, in the server's answer; it is nowNot Found. The argument is still accepted, and may be left out. -
An empty
Hostis accepted when the target names no host (SPEC B20). RFC 9110 §7.2 asks a client to sendHostwith an empty value when the URI it requests has no authority, and every emptyHostwas answered 400. It is accepted on an origin-form (/path) or*target, and the application reads an emptyHTTP_HOST; beside an absolute-form target (http://host/path) it is still refused, as is a missingHost. -
Connection: closecloses the connection whicheverConnectionline carries it (SPEC B22). A request with twoConnectionlines was read by its last alone, socloseon the first was lost and the connection kept open. The lines are now one list, in order, as RFC 9110 §5.3 reads them, and the application sees them combined. -
A request head with a bare LF is answered 400 as soon as it arrives (SPEC B23). A head of bare-LF lines (
GET / HTTP/1.1\nHost: x\n\n) was never served, but got no answer until the client closed or the header timeout sent 408, holding a connection slot meanwhile. -
A chunked body whose trailer value holds a control byte is refused with 400 (SPEC B24), as a request header's value is. Trailers are discarded, so no application ever read one; the two rules now agree.
-
An inbound WebSocket message no longer waits on a quiet pool lane for the next request (SPEC I39). Exposed: m0serve under
--realtimewith a handler pool, its default, where a Python view holds a WebSocket withM0-Hold: websocket. Each message is handed to the pool with a wake for a thread that is parked, and one that arrived while none was (every thread busy, spinning, or a moment from parking) woke nobody: the thread that parked next did not look for it, and the view was handed it only when a later request woke the pool, on a quiet server never. A thread now looks for a message once more after it announces its park. Found by the fork review; with a sleep holding that moment open, 24 messages of 24 waited. -
A cross-worker bus frame is delivered whole or not at all (SPEC I40). Exposed: a publish whose channel name and frame together passed about 69.6 KB, through
m0pub.publish(),scope["state"]["m0"],BroadcastBus.publishorDatastarStreamwith the bus: a 5,000-byte channel with a 64 KB frame reached the other workers' subscribers 904 bytes short, the end of the frame silently missing, though every publisher had accepted it. Each loop now reads every datagram a publisher can send whole, up to a 65,535-byte channel with a 65,536-byte frame, into one buffer it keeps instead of allocating and zeroing 70 KB on every drain. A datagram longer than that, or malformed, is refused and counted:http_bus_frames_refused_totalon/__metrics, which counts the stream chunk channel's datagrams too (an ASGI executor and a streaming--blocking-threadspool write that channel in the same codec, and the same reader drains it). -
A
DatastarStreamreads aLast-Event-IDthe way the server's held streams do. Exposed: an m0 application that callsDatastarStream.openwith a request it built itself, whoseLast-Event-IDcarries whitespace around the id.openread" 12 "as 0 and replayed the whole journal where the client had seen up to 12; it now resumes after 12. The stream and the server's held streams share one parser (lightbug_http.hold.request_last_event_id), so the two cannot read an id differently again. A request from the wire was never affected: the server trims the whitespace before either parser sees the value. -
WebSocket frames and upgrades the RFC calls malformed are refused with 1002 or 400 (SPEC I35-I38). A client frame whose 64-bit length has its high bit set, or whose length is not in the shortest encoding that holds it, now closes the connection with 1002 (it was 1009, or accepted). A control frame (ping, pong, close) declaring more than 125 bytes is refused when its header arrives, not after its payload. The upgrade request must list
upgradeas a whole token ofConnection(Connection: notupgradewas accepted) and must be HTTP/1.1; an HTTP/1.0 request is answered 400. -
On Linux, a connection on a descriptor numbered 65536 or above no longer disturbs another's timer, or the application's tick (fork review LF18, SPEC C11). Exposed: Linux servers whose descriptor limit (
ulimit -n) lets a connection reach descriptor 65536, m0serve and Mojo applications alike. The epoll backend kept each timer in one of five regions of 65536 slots, so a higher descriptor's timer took another's slot: an SSE stream on descriptor 65536 re-armed theM0_APP_TICK_MStick and, when it ended, stopped the tick for good; a request body read on descriptor 131072 + k re-armed descriptor k's heartbeat; and a stream at or above 131072 got no heartbeat, a body at or above 262144 no read timeout. Every timer now has a slot of its own at any descriptor below 2^20, which a process reaches only with a descriptor limit (RLIMIT_NOFILE) above a million. -
A server that returns gives back its kqueue or epoll descriptor (fork review LF21, SPEC C13). Exposed: a Mojo application that serves, returns and serves again in one process (
Server.serve_nonblocking,listen_and_serve), and the Mojo host'sM0_THREADSloops as they end; m0serve and a host process exit when their loops do, so they never reached it. Each return left its multiplexer open, and on Linux every timerfd the loop still held. The backend now closes them when the loop is done with it. On Linux, an epoll registration the kernel refuses is now reported with its own error: a refused ADD was retried as a MOD, whose ENOENT was reported in its place. -
On Linux, a burst of new connections queues instead of being dropped (fork review LF19, SPEC C12). Exposed: Linux servers, m0serve and Mojo applications alike. The listener asked the kernel for an accept queue of 128, so while the loop was busy -- a slow pass, a batch of accepts -- connections past the 128th were dropped at the handshake, and each client waited a second or more to retry. The queue is now up to
SOMAXCONN(4096 on Linux, 128 on macOS), or the system's setting if that is lower. macOS's limit was and stays 128. -
Two request cookie jars of two or more cookies compare equal (fork review LF30, SPEC G22). Exposed: an m0 application that compares
RequestCookieJars with==. A jar of two or more cookies was unequal even to a jar parsed from the sameCookiefield, since every cookie of one was compared with every cookie of the other. Jars are now equal when they hold the same names with the same values, in any order. The server itself never compares jars. -
A bracketed listen address with a colon in its port is refused (fork review LF33, SPEC A31). Exposed: a Mojo application that passes
Server.listen_and_servean IPv6 address such as[::1]:8:0. The port was read after the last colon, so that address listened on a port the kernel chose, and[::1]:8:80on port 80. It is now refused at startup as too many colons, as Go'snet.SplitHostPortrefuses it. -
A listen address of
localhostwith no port is refused (fork review LF45, SPEC A33). Exposed: a Mojo application that passesServer.listen_and_serve(orListenConfig.listen) the bare wordlocalhost. It was read as the loopback at port 0, so the server listened on a port the kernel chose and named it to no one. It is now refused at startup as missing its port, as127.0.0.1alone always was;localhost:8080is unchanged. m0serve and the Mojo host always pass a port. -
A listen address's port must be digits (fork review LF46, SPEC A34). Exposed: a Mojo application that passes
Server.listen_and_serve(orListenConfig.listen) an address whose port text is not plain digits. The port was read the wayInt()reads a number, taking a sign, whitespace and underscores, so127.0.0.1:-0listened on a port the kernel chose and127.0.0.1:+80,127.0.0.1: 80or127.0.0.1:8_0on port 80. Such a port is now refused at startup. m0serve and the Mojo host check--portthemselves and pass it on as digits. -
The "listening on" line names the port the server is bound to (fork review LF47, SPEC F25). Exposed: a Mojo application that passes
Server.listen_and_serve(orListenConfig.listen) a port of 0, such as an application not on the Mojo host, which takes a port the kernel chooses. The line readLightbug is listening on http://127.0.0.1:0, a port nothing can connect to; it now names the port the kernel chose. m0serve and the Mojo host refuse a port of 0 (LF56, below) and never reach it. -
A listen address with an IPv6 zone is refused on every platform, and a refused listen address says why (fork review LF48, SPEC E31, M5). Exposed: m0serve or a Mojo host given
--host fe80::1%en0, and a Mojo application passing such an address toServer.listen_and_serve. On macOS, whose libc reads the zone into the address, it listened on that interface's link-local address and was reported without its zone; on Linux it was refused as not an address. A listen host may not contain%now, on either. m0serve and the host refuse such an address at startup with exit 78, before anything is bound, as the newaddresscheck that--doctorreports too: the server used to fail at the bind with exit 1 while the doctor said 0. Every refused listen address now names the rule it broke: the error read "Failed to parse listen address" and stopped there, and now goes on with the reason ("a listen host may not contain '%'", "missing port separator", "too many colons").AddressParseErrorcarries that reason (AddressParseError(reason)) and is no longer aCustomErrororImplicitlyCopyable. -
An
M0_*number too large to hold is ignored, not wrapped (fork review LF49, SPEC F19). Exposed: m0serve and Mojo host applications started with a numericM0_*variable whose digits pass 2^63, such asM0_PORT=18446744073709551696. The digits were added up with no overflow check, so that value served on port 80, andM0_WORKERS=18446744073709551618forked two workers. Such a value is now read as unreadable, asM0_PORT=80eightyalways was: the default is used, and m0serve's startup says so, naming the largest number it reads. -
Socket.receiveandTCPConnection.readinto a full buffer read what is waiting (fork review LF34, SPEC A32). Exposed: an m0 application that reads a socket through either, into aByteswith no room past its length (one already full, or aBytes()never given a capacity). The read asked for zero bytes and reported the peer's EOF with its bytes still waiting. The buffer now grows by 4 KB before the read. The server itself reads through its event loop and never called these. -
/__metricscounts a 101 in a1xxstatus class (fork review LF32, SPEC F23). Exposed: m0serve and Mojo applications run with--metricsthat hold WebSockets. Each upgrade's 101 Switching Protocols was inhttp_requests_totaland in none ofhttp_responses_total's classes, so the classes summed to less than the total. It is now inhttp_responses_total{status="1xx"}, andhttp_requests_total's help text says what it has always counted: requests answered, as each response's head lands, not requests received. -
A
Hostthat names no host is refused with 400 (fork review LF64, SPEC B27). Exposed: m0serve and every Mojo application. A request whoseHostwas not a host name, an IP address or a bracketed IPv6 address, with an optional port of digits --Host: a b,Host: u@a, a name in raw UTF-8 -- was served, and an application that reads the site fromHostread it. RFC 9112 asks for 400, which is now the answer, and so is it for an absolute-form target (GET http://.../) whose authority has the same fault. An emptyHostis still accepted where the target names no authority. A client sends an internationalized name as punycode, as browsers do. An IPv6 zone ID inHost([fe80::1%25eth0], or the raw%eth0older Pythonhttp.clientbuilds send) is refused too, deliberately: browsers, curl and Go never send one. -
A
Transfer-Encodingthat only Unicode reads aschunkedis refused (fork review LF58, SPEC B3). Exposed: m0serve and every Mojo application behind a front end that forwards a transfer coding it does not know. Coding names were lowercased by Unicode's rules, which turn the KELVIN SIGN (U+212A) intok, soTransfer-Encoding: chun<U+212A>edwas read aschunkedand its body framed by chunks, where every other server and proxy reads an unknown coding: two framings of one request, the ingredient of request smuggling. Coding names are now compared in ASCII, and that request is refused with 400 like any other whose last coding is notchunked. -
req.uri.hostandreq.uri.portno longer depend on the request's target (fork review LF54, SPEC A37). Exposed: a Mojo application that reads them. With the server on0.0.0.0:8973,GET /xread host0.0.0.0:8973and no port whileGET /x?q=1read host0.0.0.0and port 8973. Every request now reads the server's host and port. The host a client asked for is theHostheader, which forGET http://h/pish.HTTPRequest.from_parsedtakes the server's host and port, split once bysplit_server_address, where it took the whole address. -
A chunked request body whose chunk header holds something other than the size and an extension is refused (fork review LF60, SPEC B28). Exposed: m0serve and every Mojo application. Whatever followed a chunk's size up to the line's end was skipped, so
5 5was read as a chunk of five bytes where other servers refuse it or read 0x55, and a control byte inside a chunk extension was accepted. Such a body is now refused with 400. A chunk extension (5;name=value) is still accepted and ignored, and so, as a leniency (the grammar has none), is whitespace between the size and the line's end. -
Upgrade,RangeandAcceptare case-folded in ASCII only (fork review LF63, SPEC I38, J2, G14). Exposed: m0serve with WebSockets or--static, and Mojo applications that negotiate a vendor media type. Each was compared after Unicode lowercasing, which turns the KELVIN SIGN (U+212A) intokand an overlong two-byte encoding into an ASCII letter:Upgrade: websoc<U+212A>etwas answered 101 and the connection switched to WebSocket where every other server and proxy reads an unknown protocol, aRangewhose unit was such a spelling ofbyteswas served as a range, and anAcceptnaming such a spelling of a registered vendor type matched it. Each is now refused or ignored as other servers do, andWebSocket,BYTES=andApplication/VND.Xstill match. -
An
Acceptweight is read from the parameter namedq, in any case (fork review LF69, SPEC N53). Exposed: Mojo applications that negotiate withparse_accept. The weight was found by searching an entry's parameters for the textq=, sotext/html;Q=0did not refuse HTML, a parameter such asxq=0refused its type, andq= 0.5, with a space after the=, read as a refusal. The weight is now the first parameter whose name isqin any case, and spaces around its=are read past. -
HTTPRequest.encode()writes the target it was given (fork review LF62, SPEC A38). Exposed: a Mojo application that writes a request out (a proxy view, a test), andString(req). The request line carried the percent-decoded path:/a%20bwent out as/a b, an encoded CR LF in a URL as a real line break that split the request, andOPTIONS *asOPTIONS /. It now carriesreq.uri.request_uri, the target as it arrived or as the URL spelled it. -
The memory a large upload grew is reused by later connections, not kept for its connection's slot (fork review LF25, SPEC C17, I42). Exposed: m0serve and Mojo applications that accept large request bodies. A connection's receive buffer grows to the largest request it holds, and closing the connection kept that buffer for the next connection on the same slot alone: a burst of 64 concurrent 4 MB uploads pinned about 400 MB to the slots it used, and a second burst arriving while those slots were busy added as much again. A buffer grown past 64 KB now goes back to the allocator when its connection closes, as does the buffer a large response left on a keep-alive connection, and a WebSocket's buffers when the socket closes, so later bursts reuse that memory instead of adding to it. On macOS the process's resident size after a burst can still stay near its peak, the allocator keeping what is freed for reuse; on Linux the freed buffers go back to the system. A fragmented WebSocket message now reaches the application without being copied once more.
-
A request body read after its headers is measured by its own size (SPEC C18, fork review LF70). Three uploads within the limits were refused depending on how the client's writes fell after its headers. A body whose last bytes came in the same read as the next pipelined request was answered
400 Bad Requestwhen the headers and body were both near their limits; a chunked body at--max-bodywas refused413when its chunks came after its headers, though accepted when they came with them; and a chunked body over the limit behind long headers got400and an immediate close, where every other oversized body gets413and the lingering close that lets a client still uploading read it. A body read after its headers is now measured by its own size and, for a chunked one, by what its framing cost. Headers are held to the receive buffer's limit on their own (fork review LF72, below). -
A client that pipelines a burst of requests no longer stalls the server (SPEC A41, C19, fork review LF72). A client that sent many requests at once without waiting for the answers -- 20,000 small GETs, about 540 KB -- held the event loop for more than a second and a half while they were answered, every other connection on that loop waiting, and the cost grew with the square of the burst. A connection now gets one read's worth of the loop at a time and the rest on later turns, and a burst costs the same per request whatever its size (1.2 µs a request measured for both 2,500 and 20,000, where 20,000 cost 78 µs each). Pipelining in moderation is cheaper too: 16 requests at a time cost about a quarter less per request. With the limits an application can lower in
ServerConfig, such a burst was also refused400 Bad Requestpartway through, and a request whose body, or a request behind it, came in the same read as its headers was refused 400 where it was answered when they came apart. A request's headers are now held to the receive limit on their own, whatever arrives behind them. -
100 Continueis never cut short ahead of a response (SPEC A42, fork review LF73). A client that sendsExpect: 100-continue(curl does for a large upload) is told to go ahead with an interim response before the final one. On Linux, when the connection's send buffer was nearly full -- a client pipelining a request behind answers it had not read yet -- the kernel could take only part of it, and the rest was dropped: the client then read a fragment followed by the real response, which it cannot parse. What the send does not take now goes out first: ahead of the answer, so the client reads a whole100 Continue, late if the buffer was full, then its response; and in one piece with an error that ends the request, so a send cut short leaves the stream truncated before the close, never an error behind a fragment. -
A client still sending its headers when the header timeout passes reads the
408(SPEC A5, fork review LF75). The server answered408 Request Timeoutand closed the connection with the rest of the client's headers unread, which resets the connection: on Linux the client saw "connection reset by peer" instead of the 408. The 408 now goes out with the same lingering close a413gets -- the server stops writing, reads and discards what the client is still sending for up to five seconds, then closes -- so the client reads the answer.
Changed
-
A port outside 1-65535 is refused with exit 78, from a flag or the environment alike (fork review LF56, SPEC E30, E31, M2). Affects m0serve and Mojo host applications given
--port 0,M0_PORT=0, or a port past 65535.M0_PORT=0served on a port the kernel chose, which m0serve's startup line named as:0, while--port 0was a usage error (exit 2). Both are now read and then refused at startup with exit 78, before anything is bound, as a count the server will not serve already was:M0_PORT must be between 1 and 65535, got 0from m0serve andM0_PORT must be 1-65535, got 0from the host, the fix naming--portandM0_PORT.--doctorreports it as theportcheck, now the first of each list. A script that read exit 2 for--port 0reads 78 now. -
The fork's descriptor helpers live in the module that owns them (fork review LF28). Nothing served changes. An application built with the
m0wheel that imported one of these from the old place imports it from the new:set_nonblocking,is_nonblocking,F_GETFLandF_SETFLfromlightbug_http.c.fcntl, no longerlightbug_http.c.kqueue(they serve Linux too);O_NONBLOCKandO_CLOEXECfromlightbug_http.c.fcntl, no longerlightbug_http.c.socket; andset_tcp_nodelayfromlightbug_http.c.socket. -
recvandsendtake their length from the span they are given (fork review LF20, SPEC G21). Nothing served changes. The two calls inlightbug_http.c.sockettook a length beside the span, which the span did not have to back: a count above the span's length was written past its end. They now read or writelen(span)bytes and no more, so an application built with them0wheel that called one drops the length argument, slicing the span to the count it meant. To receive into a list's spare capacity, passspare_capacity(list)and grow the list by whatrecvreturns. -
HTTPRequest.from_parsedtakes the server's host and port (fork review LF54, SPEC A37). An application built with them0wheel that calledfrom_parsed(server_addr, parsed, body, max_uri_length)passesfrom_parsed(host, port, parsed, body, max_uri_length), splitting its address once withsplit_server_address. Andreq.uri.hostno longer carries the port on a request whose target has no query or escape: with the server on0.0.0.0:8973it was0.0.0.0:8973there and0.0.0.0elsewhere, and is0.0.0.0now, the port inreq.uri.port. -
HTTPRequest.encode()andwrite_towritereq.uri.request_uri(fork review LF62, SPEC A38). An application that edits a parsed request'suri.pathoruri.query_stringand then writes the request out (a proxy-style view) setsuri.request_uriinstead: the writers no longer rebuild the target from the decoded path and the query.
Removed
-
The fork's client connect path, and the last of its dead C bindings (fork review LF26). Nothing served changes: m0serve and the Mojo host never open a connection, and reached none of it. The
m0wheel ships the fork's source, so an application built withm0that named one of these needs its own copy:Socket.connectandlightbug_http.connection.create_connection, with theconnect(2)binding inlightbug_http.c.socket; thegetaddrinfomachinery under them inlightbug_http.address(getaddrinfo,get_ip_address,CAddrInfo,AnAddrInfo,addrinfo_macos,addrinfo_unix,freeaddrinfo,gai_strerrorand their three error types), whose iterator never ended on Linux;TCPConnection.set_recv_timeout,Socket.set_timeoutandSocketOption.SO_RCVTIMEO;NetworkType'sSUPPORTED_TYPES,TCP_TYPES,UDP_TYPESandIP_TYPES;lightbug_http.c.address.AddressInformation, whoseAI_*values were Linux's on macOS too;lightbug_http.c.network'saddrinfo, laid out as Linux's on every platform,in6_addrandsockaddr_in6;try_writev; andkevent_register,EV_ENABLEandEV_DISABLE. -
Dead parts of the fork's request, response and server types (fork review LF61). Nothing served changes. The
m0wheel ships the fork's source, so an application built withm0that named one of these needs its own copy:lightbug_http.StatusCode(lightbug_http.http.response. StatusCode), whose one reader wasHTTPResponse.is_redirect, which left 303 out;HTTPResponse.content_length(),connection_close()andlen(response);lightbug_http.http.request.RequestMethod,RequestBodyTooLargeErrorandCookieParseError, which nothing raised (RequestBuildErrorisURITooLongErrororURIParseError);HTTPRequest.set_connection_close()and its==andis not;Server.max_request_body_size(),max_request_uri_length()and their setters (setServerConfig's fields);ProvisionPool.size();RequestParseError.is_incomplete()(askisa[IncompleteHTTPRequestError]());parse_request_headers'last_lenargument, whose rescan for the end of a head the server had already framed changed no answer; andlightbug_http.strings.http, whose last reader went withURI.is_http. -
lightbug_http.c.epoll'sEPOLL_CLOEXEC,TFD_CLOEXECandTFD_NONBLOCK(fork review LF28), copies of the open flags under Linux's other names. Nothing served changes; an application built with them0wheel that named one passesO_CLOEXECorO_NONBLOCKfromlightbug_http.c.fcntl, the same values. -
The fork's response parser, its
Set-Cookieparser and unused helpers (fork review LF26). Nothing served changes: the server parses requests, never responses, and writes an application'sSet-Cookieas given. Them0wheel ships the fork's source, so an application built withm0that named one of these needs its own copy:HTTPResponse.from_bytes, theHTTPResponseconstructor from aByteReader,read_bodyandread_chunks;lightbug_http.header.parse_response_headersandParsedResponseHeaders,lightbug_http.http.parsing'shttp_parse_response_headers,get_token_to_eolandtry_peek_at; the response parse errors,lightbug_http.header'sResponseParseError,InvalidHTTPResponseErrorandIncompleteHTTPResponseError, andlightbug_http.http.response'sResponseParseError,ResponseHeaderParseErrorandResponseBodyReadError;ResponseCookieJar.from_headers;Cookie.from_set_header, which droppedexpires, a capitalisedSameSiteand any attribute it did not know;Cookie.clear_cookie; withlightbug_http.cookie'sCookieParseError(the request side's went too, below),InvalidCookieError,Expiration.invalidateand thefrom_stringofExpiration,DurationandSameSite;ParsedRequestHeaders.expects_body, which missed a chunked body on any method but POST, PUT and PATCH;write_header_latin1, which wrote a header holding CR or LF whereHeaders.write_latin1_todrops it;HTTPChunkedDecoder.is_in_chunk_data;lightbug_http.strings'find_all,is_printable_ascii,IS_PRINTABLE_ASCII_MASK,BytesConstant.CRLFandBytesConstant.DOUBLE_CRLF; andlightbug_http.io.bytes'is_newline,is_spaceandbufis, andByteReader'sread_line,read_word,skip_whitespace,skip_carriage_returnandconsume.parse_headersandscan_to_eollose theirstrictparameter and always read a request head's rules; the lenient reading was the response parser's. -
LoopState.fd_map_size, and the event loop's handling of an idle timer (fork review LF26): the field was set and never read, and nothing arms an idle timer (idle deadlines are swept), so its branch could not run. Nothing served changes. A connection that closes with part of a response still unsent now gives that buffer back at once, rather than when its slot next answers someone. -
The fork's network and address code that nothing calls (fork review LF44, LF45, LF48). Nothing served changes. The
m0wheel ships the fork's source, so an application built withm0that named one of these needs its own copy:NetworkType'sudp,ip,ip4,ip6,unixandempty,is_ip_protocolandis_ipv4, andParseIPProtocolPortError, which only anipnetwork raised;lightbug_http.address.DEFAULT_IP_PORT;validate_no_brackets'end_idxparameter, never passed;TCPAddr's zone (the field and its constructor),address_family,is_v4,is_v6,is_unix,==,__str__,__repr__andwrite_to, with theAddrrequirements behind them;binary_port_to_intandntohs;Socket'sremote_address, with theremote_addressparameter of both constructors that remain, its constructor without a family,get_peer_name,__enter__,__str__,__repr__andwrite_to, and thegetpeernamebinding;NoTLSListener's constructor that made its own socket,shutdown,teardownandaddr;TCPConnection'sshutdown,teardown,local_addrandremote_addr;ConnectionState.closedandConnectionState.CLOSED;AddressParseError'sCustomErrorandImplicitlyCopyableconformances and its empty constructor (it now takes the refusal's text,AddressParseError(reason), and moves);ListenerError'sErrorarm andSocketNameError'sInetNtopErrorarm, neither ever raised; the__getitem__and__str__of the address, listener, socket andinet_*error variants, and theisaofParseError,ListenerError,SocketNameError,InetNtopErrorandInetPtonError;SocketAddress.as_sockaddr_inandsockaddr's constructor;AddressFamily.is_inet,AddressLength.INET_ADDRSTRLENandShutdownOption.SHUT_RD; and thewrite_toand__str__ofAddressFamily,AddressLength,ShutdownOption,SocketOptionandSocketType, with the==of the last four. -
The fork's unused URI, cookie, response and byte helpers (fork review LF52). Nothing served changes, and no application in the tree, the
m0templates or the one outside it named any of these; an application built withm0that did needs its own copy. Fromlightbug_http.uri:URI'susername,password,full_uri,_original_pathand_hashfields (always empty, or the path again), its__str__,__repr__,is_httpandis_https, the unusedQueryDelimitersandURIDelimitersconstants, andURIParseError.__str__(String(error)still writes it). Fromlightbug_http.cookie:Expiration, a stub that could only say "session", withCookie'sexpiresfield and argument (aCookiesets its lifetime withmax_age);Cookie.to_headerandCookie.__str__;SameSite.__eq__;RequestCookieJar's constructor from cookies,parse_cookies,empty,encode_toand itsinfor aCookie; andResponseCookieJar's constructors from cookies, its[],getandin. Fromlightbug_http.http: the twoOKoverloads takingBytes,SeeOther(also exported fromlightbug_http;m0_http.reply.redirectbuilds every redirect) andBadRequest(message). Fromlightbug_http.io.bytes:OutOfBoundsError,EndOfReaderError.__str__,ByteReader'sread_bytes(n),as_bytesandin, andByteView's comparisons,in, truth test,as_bytesand__str__. Fromlightbug_http.strings:https,colonCharand the seventeenBytesConstantbytes nothing reads. -
Unused names at the top of the fork, and
HTTPRequest.timeout(fork review LF71). Nothing served changes. Them0wheel ships the fork's source, so an application built withm0that named one of these imports it from its own module instead:URI,Cookie,RequestCookieJarandResponseCookieJarare no longer re-exported fromlightbug_http(import them fromlightbug_http.uriandlightbug_http.cookie), norBytesfromlightbug_http.io(import it fromlightbug_http.io.bytes).HTTPRequest'stimeoutfield and constructor argument are gone, withlightbug_http.io.sync.Duration, the alias forIntit was declared with: nothing read it.
1.12.1 — 2026-10-07
A patch release for the security fixes of a review of the server's HTTP
core, the lightbug_http fork. A header value could split a response built
in Mojo, and a request head ending a line with a bare LF put the parser and
the event loop's framing out of step in every server, m0serve included; on
macOS the page workers share could be created readable by other users. The
review's other fixes are here too: request heads, trailers and targets the
RFCs call malformed are refused or read as they say. The served contract is
unchanged. The m0 wheel ships the framework's source, so an application
built with m0 takes every fix by rebuilding against m0 0.9.1. This
section also records what m0 0.9.0 published on its own: the board
template and m0 new ..
Security
-
A header value can no longer split a response with an overlong UTF-8 sequence (SPEC G19). Exposed: responses built in Mojo only; the gateway's Python applications are not. The latin-1 transcoder that writes every response head decoded three- and four-byte sequences as well as two-byte ones, AFTER the check that drops a header carrying CR, LF or NUL, so the overlong forms
E0 80 8DandE0 80 8Awent out as a real CRLF. A Mojo view that redirected to a request'snextwithreply.redirectcould be made to send aSet-Cookie, or a body, of the request's choosing:?next=/%E0%80%8D%E0%80%8ASet-Cookie:%20sid%3Dxdid it, and a header orSet-Cookievalue a view built from request data was open the same way. Every response built in Mojo was exposed: aViewsapplication's, the Mojo host's, and a Mojo mount's in m0serve. Python applications on m0serve were not: astrheader reaches the server as CPython's UTF-8, which is never overlong, and abytesvalue is re-encoded byte by byte. The transcoder now decodes only U+0080 to U+00FF and writes every other byte as it was given, and the writers check the transcoded bytes again before they send them. Upgrade m0serve if it serves a Mojo mount, and rebuild every Mojo application against this release (anm0application againstm0 0.9.1). -
A request head with a bare LF is refused with 400 (SPEC B12). Exposed: every server, m0serve included, whatever it serves; the parser and the event loop are the same under all of them. Upgrade m0serve, and rebuild every Mojo application. The parser ended a head at a bare-LF empty line (
\r\n\n), while the event loop frames a head by the first CRLFCRLF, and the bytes between the two were lost: a request pipelined behind such a head got no answer, and aContent-Lengthafter the bare LF was never read, so the body it described was answered as a request of its own. Every line of a request head must now end in CRLF, and the loop also refuses any head the parser ends at a different byte than its own frame, then closes the connection. A client that ends request lines with a lone LF is refused; none is known.smoke-pipeliningsends both shapes. -
The page workers share is created private on macOS (SPEC G20). Exposed: macOS only; the mode was measured world-readable once. The server asked
shm_openfor mode0o600in a register, where Apple silicon passes that argument on the stack, so the page took whatever mode the stack held: measured as0o000,0o001and0o744, the last readable by any user who opened it by name before the server unlinked it a moment later. It is now created0o600. A page that cannot be sized no longer leaks its descriptor. Linux was not affected.
Added
-
m0 0.9.1, this release's framework, for applications built withm0. The wheel ships the framework's source, the server's HTTP core included, so an application takes every fix under Security and Fixed by rebuilding against it: raise its pin tom0==0.9.1, thenuv syncanduv run m0 build. Nothing in an application's own source has to change. What changed sincem0 0.9.0for someone writing an application:- Fixed, the response splitting under Security. Every response an
m0application builds is built in Mojo, so every application built with an earlierm0is exposed until it is rebuilt. - Fixed, the bare-LF framing desync under Security, and the request, WebSocket and static-file fixes under Fixed.
- Changed,
m0 --helpnames the templates, and anm0 newgiven no template says it took the default (under Changed).
- Fixed, the response splitting under Security. Every response an
-
m0 0.9.0, published on its own, for applications built withm0. What changed sincem0 0.8.0for someone writing an application:- New,
m0 new --template board: a list every tab shares, whose sender is a view publishing through the state'sDatastarStream. - New,
m0 new .: writes the empty current directory, named for it. - The next step
m0 newprints serves on127.0.0.1. - Fixed,
livebehind a handler pool: a projectm0 0.8.0or earlier wrote takes the two lines under Fixed below. - The scaffold's
AGENTS.mdchanged, som0 doctornames it in a project an earlierm0wrote. The framework's own source is unchanged sincem0 0.8.0.
- New,
-
m0 new --template board, andm0 new .(SPEC N27, N52, D66; docs/notes/what-the-agent-runs-asked-for.md). The follow-ups from the agent usability runs, where every Mojo run wrote./NAMEand moved it up a level, then deleted half ofliveto get a list a request pushes.boardis a list every tab shares whose sender is a view: a POST appends and publishes the whole board through the state'sDatastarStream, with no producer and no database. Its views run on the loop, and the gate serves it behind a handler pool.m0 new .writes the empty current directory and takes its name.- The printed next step serves on
127.0.0.1.
-
Skills for m0serve and m0 in
skills/, written from where the agent runs looked.
Fixed
-
A response whose application sets
Serverno longer carries twoServerlines (SPEC A26). The server wrote its defaultserver: lightbug_httpwhatever the headers held, so an application that named itself, built in Mojo or a Python application on m0serve, sent two. The default is now written only when the application set none. -
A folded field line is refused with 400 (SPEC B13). A request field line opening with a space or a tab (obs-fold, RFC 9112 §5.2) was accepted as a field with an empty name: a WSGI application saw an environ key
HTTP_, and the folded text never joined the field it continued. Such a request is now refused. -
A chunked request body's trailer is held to field lines ending in CRLF (SPEC B14). After the last chunk the trailer section skipped any run of CR, took a bare LF as a line end, and took any line as a field, so
0\r\n\nended a body that a stricter proxy in front still reads as open, and a trailer line with no colon was served where Node's llhttp and h11 refuse it. Each now makes the body invalid, answered 400, as a bare LF in a chunk extension already was. Trailers are still discarded, never handed to the application. -
An HTTP/1.0 request with a chunked body closes its connection behind the answer (SPEC B15). RFC 9112 §6.1 calls
Transfer-Encodingon an HTTP/1.0 message faulty framing, and asks for the connection to close after it. Such a request was de-chunked and, withConnection: keep-alive, kept alive, so a request pipelined behind it was answered too. It is now answered and the connection closed. AndHTTP/1.2throughHTTP/1.9, which are served as HTTP/1.1, now need aHostfield as HTTP/1.1 does; one without was served. -
Connection: closecloses wherever it stands in the list (SPEC B17).Connectionis a list of options (RFC 9110 §7.6.1), and the server compared the whole value withclose:Connection: close, TEkept the connection alive and answered the request pipelined behind it. Each option is now read on its own, case-insensitively, and an HTTP/1.0 client'skeep-aliveis read the same way. Under--metricsthe/__metricsanswer kept its connection alive whatever the request asked; it now closes as every other answer does, for this and for an HTTP/1.0 chunked request alike. -
A request whose target is a whole URL takes its host from the URL (SPEC B16). RFC 9112 §3.2.2 says a server receiving
GET http://example.com/p HTTP/1.1uses the target's host and ignores theHostfield. The server threw the target's host away and keptHost, so an application routing onHost(Django'sHTTP_HOST) read a site the target never named. The target'shost[:port]now replacesHost. The scheme is matched in any case (HTTP://h/preached the application as the pathHTTP://h/p), a query straight after the host is kept (http://h?q=1lost it), and a target with no host (http:///p) or with a userinfo (http://user@h/) is refused with 400. -
A WebSocket message is no longer lost when a frame the server refuses follows it in the same read (SPEC I34). A frame the protocol forbids (a reserved opcode, an unmasked frame, a text frame that is not UTF-8) ends the connection with a Close, and that refusal discarded everything parsed from the same read before it: a complete message never reached the application, and a ping went unanswered. Whether a message arrived depended on how TCP split the bytes. Both are now kept, the Close sent after the pong.
-
A static file that shrinks while it is served no longer spins its connection (SPEC J15). When a file ended before the
Content-Lengthits response had already sent, the server read the end of the file as a socket that could take nothing yet and waited for room it already had: the connection spun on the event loop until the idle timeout closed it, and for good with idle timeouts off. It now closes at once, and the client sees a response shorter than its length, an error it can detect. -
The
livescaffold's stream behind a handler pool. Its/eventsview was noton_loop, and its handler answered only stateless loop routes, so under--blocking-threads(M0_BLOCKING_THREADS) every stream was refused 409. A project an earlierm0wrote takes the same two lines:on_loop=Trueon the/eventsregistration insrc/views.mojo, andself.views.answer_on_loop(req, self.state)inLiveHandler.before_request.smoke-scaffoldnow serveslivebehind a pool.
Changed
-
m0 --helpnames the templates (SPEC N27; docs/notes/what-the-agent-runs-asked-for.md). Every Mojo run without the skill ranm0 --help, then a barem0 new .before it knew a template's name, tookviews, and wrote its application a second time. The top-level help now lists the four, a line each, and anm0 newgiven no template says it took the default and lists the others. It ships inm0 0.9.1. -
The pages say what the agent runs had to find out. Both quickstarts serve on
127.0.0.1. The Python quickstart says where libpython comes from, whatpublish()does with a line break, and that a form post meets Django's CSRF check. The home page and the Mojo overview point an agent atllms.txtand each page's Markdown. The overview maps the API to its pages; the views page givesreply,reply.problem's four arguments, and Datastar 1.0's rule that an action's answer applies only on a 200. The scaffold'sAGENTS.mdnames the reference pages before the installed source, som0 doctorreports it as changed in a project an earlierm0wrote.
1.12.0 — 2026-10-06
A held stream that reconnects is caught up on what it missed. Two of the
known issues were taken up: the first, a WSGI hold that replayed nothing,
is retired by a per-loop journal; the second, the RHEL 9 glibc floor, was
measured and found to be the Mojo runtime's, so its record is corrected
rather than closed. A Last-Event-ID from a previous incarnation is
clamped instead of starving the stream. The served contract is unchanged,
with one addition, the m0-gap event; the m0 wheel ships as m0 0.8.0.
Added
-
m0 0.8.0: this release's framework, for applications built withm0. What changed sincem0 0.7.0for someone writing an application: new,m0_http.ReplayJournal(m0_http.sse.replay), the bounded journal m0serve's holds are caught up from, usable beside an application's ownSSERegistry. Nothing an application has to change. -
A held stream that reconnects is caught up. Each loop journals the last
--replay-framespublished frames (M0_REPLAY_FRAMES, default 64; 0 keeps none), and aM0-Hold: streamthat reconnects withLast-Event-IDis sent every frame of its channel it missed, in order, before the live feed — whether the hold was taken inline, on a pool thread, by a Mojo mount or by a hold mount. When the journal cannot supply all of them, the client is sent none and one unnumberedevent: m0-gapframe whose data names the id it presented and the newest id allocated, so it can fetch instead of polling beside the stream (SPEC I33; the record).--doctorreportsreplay_frames.
Changed
-
A
Last-Event-IDahead of the counter no longer starves the stream. A client holding an id from a server that has since restarted presented one this incarnation never allocated, and the registry took it literally, suppressing every frame until the counter passed it. It is now clamped to the newest id and reported as anm0-gap. -
The platform note on RHEL 9 is corrected. The README and the known issue said the Linux floor was the build host's and that a
manylinux_2_34container build would reach glibc 2.34. Measured on the 1.11.0 wheels, the floor is the Mojo runtime's own (GLIBC_2.35inlibAsyncRTRuntimeGlobals.so, GCC 12's libstdc++ inlibKGENCompilerRTShared.so), so no build of ours lowers it; the wheel tag script now names the file that set the floor on every build (the measurement). -
The benchmark page's ASGI per-core verdict is corrected. It said uvloop leads the executor per core on macOS and that Linux answers at or above parity. The macOS figure had crossed 1.0 at the 1.10.0 re-record, and both a container and a rented x86-64 box put the default executor a little behind uvloop per core (0.91x, 0.94x). The one-thread loop (
M0_INVERTED=1) leads uvloop per core everywhere measured, 1.27–1.42x, and at 16 connections it also serves more than the default on Linux (the record).
Fixed
- A
--blocking-threadsstream's abort can no longer be lost beside its head. The loop read the completion ring and then the completion channel. A pool thread completes a stream's head onto the ring and, when the chunk channel refuses the body, sends that stream's abort on the channel. If the push and the send both landed between the loop's two reads, the loop met the abort with no head, dropped it as an abort for a slot that is not streaming, and then opened the stream, which stayed open, kept alive by its heartbeat. The loop now reads the channel first, so an abort it takes always has its head on the ring (the record). The window is too narrow to reproduce on demand;test_offload.mojopins the new order.
1.11.0 — 2026-10-06
What an application moved from uvicorn met, fixed. franchise-assessment's
move onto m0serve found a lifespan startup that failed without a word, an
access log whose numbers were strings and whose byte count was the
response head, static files served without the security headers its
middleware set (and, for some types, as downloads where Starlette
displayed them), and two limits only a flag could reach. Each is fixed
here, and docs/RUNNING.md says in a few lines what a uvicorn command
line does not show. The access log's line changes shape (Changed, below);
the served contract is unchanged; the m0 wheel ships as m0 0.7.0.
Added
-
m0 0.7.0: this release's framework, for applications built withm0. What changed sincem0 0.6.0for someone writing an application:- New:
StaticFiles(..., headers=...)(J11), andstatic_headers,svg_policy_forandSVG_SANDBOX_POLICYinm0_http. EveryStaticFilesanswer carriesX-Content-Type-Options: nosniff(J12), an SVG carries a sandboxingContent-Security-Policy(J14), and the type table follows Python'smimetypes(J13). - What an application may have to change: whatever reads its access
log (
M0_ACCESS_LOG), whose numbers are now JSON numbers and whosetsis nowtime(F20, F21); and an SVG served byStaticFilesthat scripts itself, which stops scripting. - Nothing an application built by
m0 0.6.0must act on otherwise: the templates are unchanged, andm0 doctornames no scaffold file.
- New:
-
Static files carry their types (SPEC J13). The table behind
--staticandStaticFilesknew 19 extensions and sent everything else asapplication/octet-stream. An app moved from Starlette or Django, both of which use Python'smimetypes, lost about 45 types, and an image or audio file of one of those types downloaded when opened directly, in Chromium, Firefox and WebKit, where it had been displayed or played. The table adds fonts (woff,ttf,otf),avifandbmp, audio and video (mp3,m4a,ogg,wav,flac,aac,mp4,webm,ogv,mov), captions (vtt), the web app manifest, source maps, feeds, calendars, CSV and TSV, JSON-LD, GeoJSON, YAML (RFC 9512), Parquet,zipandgz. A proxy compresses by type, and this server leaves compression to it, so text labelledoctet-streamused to go out uncompressed. Build inputs (.jsx,.tsx,.ts), model weights and JSON Lines stayapplication/octet-streamon purpose. -
--static-header 'Name: value'(SPEC J11): repeatable, adding a header to every response a--staticmount answers, errors included. These are the security headers an application's middleware sets, which static responses never pass through. franchise-assessment'sX-Frame-Options,Strict-Transport-Security,Referrer-Policyand the rest were missing from its CSS, JS and icons. The flag is refused with exit 2 for a name that is not a token, a control character, a name given twice, a name the static server sets itself (Content-Type,ETag,Content-Range,Allowand the framing headers), andCache-Control, which--static-cache-controlsends on successes only. In Mojo,StaticFiles(..., headers=...)is the same; there, a name the response already carries is left as the response set it. -
M0_MAX_BODYandM0_BODY_TIMEOUT, the environment forms of--max-bodyand--body-timeout(SPEC C1, A23), read with the flags' own parsers (8m,30) and beaten by the flags. A container raises either without a start script. -
The first refusal of each kind names its knob. The first time a server answers 413 for a body over the cap, it prints one line naming
--max-body, the cap in effect andM0_MAX_BODY. The first time it ends a body that stopped arriving, it prints the same for--body-timeout. Each line is printed once per event loop. The 413 says only "Payload Too Large", and the application never sees the request, so until now nothing in any log said which setting refused an upload. -
docs/RUNNING.md's "Coming from uvicorn": the behaviour a uvicorn command line does not show, one line each, linking to where each is described: buffered and capped bodies, the body timer, the longer keep-alive, the access log off and in JSON, forwarded headers not applied, exit 1 for a failed lifespan startup, and ASGI refused on a free-threaded CPython. That refusal now says how to pin a GIL build with uv (3.13in.python-version); uv had picked 3.14t for franchise-assessment.
Changed
-
wyhash64multiplies once (the ETag hash inm0-core). Its mixer assembled the 128-bit product from four 32x32 partial products behind a note that Mojo exposed noUInt128; it does at the 1.1.0 pin, and the stdlib's own AHash folds aUInt128product the same way. The one widening multiply gives the same value for every input — the pinned vectors, every length to 300 and 20 million random pairs agree, so no served ETag moves — at about twice the throughput at every size, measured on an M-series Mac: 4.5 to 7.3 GB/s on 16 bytes, 11.6 to 22 GB/s on a megabyte. A 20-byte vector joins the pinned set, covering the word path between the short tail and the 32-byte block.std.hashlib's AHash was measured beside it and is the slower of the two above 64 bytes; the ETag hash stays inm0-core, where its outputs are this repository's. -
Short JSON strings are escaped a word at a time (
m0-core'sescape_json_string_into). After its last full 64-byte block a string was scanned byte by byte, so every string under 64 bytes took the scalar loop, and those are most of what it escapes: access-log fields, Datastar signal values, JSON reply fields. The tail is now scanned eight bytes per step as one 64-bit word, and skipped when the 64-byte blocks covered it. The output is byte-identical over 26,055 cases, every length to 300 in five shapes among them; measured on an M-series Mac, 8-byte strings run 1.53x faster, 24-byte ones 1.56 to 1.85x, 64-byte strings with specials 1.37x, and clean 64-byte strings 0.96x. -
An SVG from a static mount is sandboxed (SPEC J14). Its 200, 206 and 304 carry
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; sandbox. An SVG's<script>never runs in an<img>, but opened directly or framed it ran as the serving site, with its cookies and storage, in Chromium, Firefox and WebKit: a stored XSS wherever a mount serves an SVG the application did not write. Starlette and Django serve it the same way. With the policy the script cannot act as the site, and<img>, CSS backgrounds and<use>sprites draw as before (measured by pixels in all three;poe browser-svg-sandbox, pre-release). This applies toStaticFilesin a Mojo application too. An SVG meant as an interactive document with its own script stops scripting; a deployment that names its ownContent-Security-Policywith--static-headerhas it on every static response in place of this one.--doctorreports the effective one asstatic_svg_policy. -
.mdis served astext/markdown; charset=utf-8, nottext/plain(RFC 7763). Agents such as Claude Code and Cursor ask for Markdown by that name (Accept: text/markdown), and the llms.txt convention names it for the pages it links. The docs site already advertised its Markdown twins astext/markdownand then served them astext/plain.llms.txtitself staystext/plain, which the convention also allows. -
Every static response carries
X-Content-Type-Options: nosniff(SPEC J12), from m0serve's--staticmounts and fromStaticFilesin a Mojo application alike. The type always comes from the extension table, never from the bytes, so a browser has nothing to sniff. Fetch's check refuses only a script or a style whose type is not JavaScript or CSS, and the table types.js,.mjsand.csscorrectly. A--static-headernaming the header replaces the value. -
An
M0_*value the environment cannot use is reported, not silently replaced. The environment stays lenient:M0_PORT=80eightystill serves on the default port. But m0serve now prints one line at startup for each such value, naming the variable, its value and what was used instead. The lines are printed by the process that read the environment, so a spawned worker does not repeat them. This covers every variable m0serve reads as a number or a switch; a switch set to anything buttrue,1,falseor0is reported as read off. -
--doctorreports the limits the server would use:max_body,body_timeout,idle_timeoutandmax_keepalive_requestsunderserverare the effective values, defaults included, where an unset flag printed -1. -
The access log's numbers are JSON numbers, and its time is the wall clock (SPEC F20, F21).
status,dur_usandbyteswere strings, sojq 'select(.status >= 500)'matched every line: jq sorts strings after numbers.ts, a monotonic millisecond count from an arbitrary origin that ordered one process's lines and said nothing else, is replaced bytime: RFC 3339 in UTC with milliseconds,"time":"2026-10-04T17:02:51.789Z", which matches a line against an application's own log. A record now reads{"time":…,"level":"INFO","msg":"access","method":"GET","path":"/","status":200,"dur_us":1234,"bytes":64,"remote_addr":"127.0.0.1"}, anddocs/RUNNING.mdlists the fields. A consumer that parsed the quoted numbers, or readts, needs a change. Nothing in this repository's own consumers or in the applications known to run on m0serve parsed either. The line also got cheaper. It is written on the event loop for every response, now straight into one buffer with the second's prefix cached per loop. That is about 170 ns a line, the wall-clock read included, against about 680 ns before, measured with the line built and not printed.
Fixed
--doctorreports the static mounts as valid JSON, escaped where they were concatenated raw, so a directory with a quote in its name broke the report. It now also reportsstatic_cache_controlandstatic_headers, the headers every static response carries.- An ASGI application whose lifespan startup fails no longer leaves a
server that answers nothing (SPEC L33). Under the asyncio executor,
the ASGI default, the application is built and its lifespan started on
the executor's own thread, and nothing waited for it: a
lifespan.startup.failedwas logged asasgi-executor raised, the ready banner had already printed, the loop went on accepting, requests hung with no bytes,--health-pathanswered 200, and SIGTERM exited 0. The server now waits for every executor's startup before it prints the banner, and a startup that fails exits 1 naming the application's message, as it already did under--blocking-threads(uvicorn exits 3). So the banner follows the lifespan startup in every mode; thearmed for a graceful stopline still precedes it, and a SIGTERM or SIGINT while the startup runs ends the worker with exit 0.--mount's ASGI applications, one executor each, are waited for alike, andM0_INVERTED's banner and exit follow the same rule (a stop there still waits for the startup, which runs on the loop's own thread). - The access log's
bytesis the response body as sent (SPEC F2). It was the length of the encoded buffer: the head plus an in-memory body, and none of a body sent withsendfile. So a 64-byte body logged 366, and a--staticfile logged about the size of its own head. It now counts the body, a file body included, and nothing for a HEAD or a 304. That is whatlog_accessnamed the value all along (body_size), and what gunicorn's and Apache's%breport. With--metrics,http_bytes_sent_totalstill counts head and body, and now includes file bodies, which it also left out.
1.10.0 — 2026-10-03
The database remembers what changed. m0_sqlite's stamps put four
triggers on a table, after which every row written there, by any program,
carries the number of its last change, and "what changed since N" is one
statement; a process that derives data from the table keeps its place in
the same database, with its outputs. m0_http.Feed is a stream whose
event ids are those numbers: a reconnect is the application's query, not
a replay, fan-out is per subscriber from where it stands, and a delta
goes whole or waits. apps/table_notes' list is live on it, from either
loop, with one line of script on the page. The served contract is
unchanged; the m0 wheel ships as m0 0.6.0.
Added
-
m0 0.6.0: this release's framework, for applications built withm0. What changed sincem0 0.5.0for someone writing an application:- New:
install_stamps,watch,watched,stamp_head,stamp_of,stamp_floor,prune_stamps(O26, O27),cursor,advanceandslowest_cursor(O28) inm0_sqlite;Feedandsince_ofinm0_http(N51);ViewState's three stream hooks andtick, each with a default, so a state that holds a stream forwards them (N51).apps/table_notesin the repository is stamps, a delta over a GET (N49) and the live list (N50) together. - What an application may have to change: nothing. A
ViewStatethat already declared a method namedtick,sse_drain_slot,sse_is_streamingorsse_slot_disconnectednow has it called byViewsApp. - Nothing an application built by
m0 0.5.0must act on otherwise: the templates are unchanged, andm0 doctornames no scaffold file.
- New:
-
m0-sqlitestamps: the database remembers which rows changed (SPEC O26, O27, N49, DECISIONS D63, D64; docs/notes/a-database-that-remembers-what-changed.md).install_stamps(db)andwatch(db, table)put four triggers on a table, after which every row written there, by any program, has one entry inm0_changesholding the stamp of its last change, the stamp it was born at and whether it is gone. "What changed since N" is then one statement, and whoever asks keeps one number.watched,stamp_head,stamp_of(one table's clock),stamp_floorandprune_stampsare the rest. A watched single-row commit measured 30 µs against 8.6 µs unwatched. What a stamp cannot see is pinned by tests: a REPLACE through a UNIQUE column needsPRAGMA recursive_triggers = ONon the writer, and a table rebuilt by a migration loses its triggers.watchrefuses a table whose key is not its rowid. -
A stage's place:
m0_cursors,cursor,advance,slowest_cursor(SPEC O28). A process or thread that derives data from a watched table records the last stamp it acted on in the transaction that writes its outputs, so a restart repeats nothing and skips nothing; lag and the safe prune point are queries. -
m0_http.Feed: a stream whose event ids are the application's (SPEC N51). Subscribers each stand at a number the application owns; a reconnect is the application's query, not a replay; fan-out is per subscriber from where it stands; a delta goes whole or waits, so a slow client gets merged events and never a gap.ViewStategains the three stream hooks and the tick, with defaults, forwarded byViewsApp. -
apps/table_notes' list is live (SPEC N50):GET /notes/eventsfrom either loop, an eight-line script on the page (anEventSourceand htmx 4'shtmx.swap), the rows as out-of-band<li>s. -
apps/table_notesanswersGET /notes/changes?since=N: the rows stamped above N as JSON, from either loop, with nothing kept for the client.smoke-table-notesholds it at one loop and at two.
1.9.0 — 2026-10-02
An application in Mojo can serve a table: Views.resource registers a
collection's routes, m0_sqlite's Connection.data_version() says when
the data may have changed, and Cached with conditional keep a rendering
until it does and answer a client that holds it 304. A Mojo function can
run inside a SQLite query. m0serve and the Mojo host listen on IPv6. A
stream's socket carries TCP keepalive, so a client that vanished is reaped
where nothing is in flight. Under --workers N, five ways a connection or
a worker could be lost at a restart or a failure are closed, and m0-sqlite
and m0-postgres each keep to one image of their library. The application
layer's milestone is met: its first application outside the tree is
recorded. The served contract is unchanged. Names nothing in the tree used
left the packages the m0 wheel ships, the Mojo HTTP client among them,
which matters only to an application that imported one; the m0 wheel
ships as m0 0.5.0.
Added
-
m0 0.5.0: this release's framework, for applications built withm0. What changed sincem0 0.4.0for someone writing an application:- New:
Views.resourceand theRESOURCE_*suffixes (N46),Cachedandconditional(N47),Connection.data_version()(O25) andConnection.create_function(O19–O22), each below;apps/table_notesin the repository is the first three together.--host ::andM0_HOSTtake an IPv6 address (M29). - What an application may have to change: the names under Removed are
gone from the packages the wheel ships —
m0_http.Client,RequestContext,check_api_keyandAppConfig.api_key,ResponseCache,PatchJournal,negotiate_encoding,negotiate_language, the freewants_htmlandwants_event_stream, m0-core's FNV-1a and xxHash32, m0-sqlite'sstats_intsfamily, and a handful of upstreamlightbug_httpnames. An application that imported one needs its own copy; none of the three templates did. - Nothing an application built by
m0 0.4.0must act on otherwise: the templates are unchanged, andm0 doctornames no scaffold file.
- New:
-
A resource over a table (SPEC N46–N48 and O25, DECISIONS D60–D62; docs/notes/a-resource-over-a-table.md), in three pieces an application joins:
Views.resource(NOTES, list=, new=, create=, show=, edit=, update=, delete=)registers a collection and its rows under one pattern, each view in the slot that says what it does. Every slot is optional.updateanswersPUT /notes/:idandPOST /notes/:id/edit, because a plain form cannot PUT and posts to the URL it was served from.RESOURCE_NEW,RESOURCE_ITEMandRESOURCE_EDITare the suffixes, for the constants given tourl_for.m0_sqlite'sConnection.data_version()is a change clock: a number that differs once another connection has committed a change to the file. Asked on a connection opened read-only beside the writer, it moves for this process's own commits too, so a cache keyed by it needs no commit hook and nothing a caller could forget to say. Ask it on the connection that renders, before rendering.Cachedkeeps one rendering and the clock value it was made at, andconditional(req, resp)puts anETagover the response's body and answers 304 to a GET or HEAD that names it. The tag is a hash of the bytes sent: a page and its fragment have two, and a commit to another table leaves both alone.
apps/table_notesis the three together over a SQLite file: two loops underM0_THREADS=2each serve what the other wrote, with no lock held across a render and nothing shared but the file.apps/fragment_notesregisters its notes throughresource, its wire output unchanged. -
Scalar SQL functions written in Mojo (SPEC O19–O22, DECISIONS D58–D59).
Connection.create_function("dot", Dot())registers a type conforming toScalarFunction—comptime arityanddeterministic, and acall(mut self, args, answer)— on that connection, and SQLite calls it from inside the query plan. The instance is the function's state:callmay write its own fields, and what it writes is there for the next call.Args.blobis SQLite's own bytes, never a copy, so a kernel over a large column costs the kernel: a dot product over 100,000 384-float embeddings scanned them in 8.6 ms, one to three percent over the same function written by hand against the C API and twice as fast as sqlite-vec'svec_distance_l2, which copies both vectors on every call. A span fromArgs.blobstays good for the whole call:Args.textrefuses a BLOB, since reading one as text can free or move its bytes. A raise fails the statement with its text, while the connection is open: on a statement stepped after itsConnection's last mention, where Mojo has already closed it, the error keeps its code and carries the code's own text ("SQL logic error"), as every error of such a statement does. Keep the connection mentioned past the step to get the function's text. The schema never computes with a registered function: SQLite refuses it in a CHECK constraint, a generated column or an index when they are created, in a stored view when it is used, in a trigger when it fires and in a column DEFAULT when an INSERT takes it. Creating a view, a trigger or a DEFAULT that names one is NOT refused, and such a trigger then fails every write to its table, from every program, until it is dropped; do not name a registered function in any of them. A library where that refusal is incomplete is refused: one older than 3.31.0 for every function, and one older than 3.50.0 for a function that says it is not deterministic, which until then a CHECK constraint could name and run. SQLite owns the instance from registration and destroys it when the function is replaced or the connection is destroyed — atclose(), or, when a statement outlives the close, at that statement's finalize, the function answering until then. A call that arrives while one on the same instance is in progress — a function that steps a statement of its own connection which calls it — fails its statement with "a scalar function was called again while a call on it was in progress" and does not run:calltakesmut self, and the inner call's writes were otherwise lost to the outer one, silently. The check costs about 0.6 ns a call. m0-sqlite registers no functions of its own. -
IPv6 (SPEC M29).
m0serve --host ::listens on IPv6 and IPv4 at once, and--host ::1on the IPv6 loopback alone; the same goes for the Mojo host'sM0_HOSTand--host, and forServer.listen_and_serveon"[::]:8080". A host may be written in brackets,[::1], as in a URL;0.0.0.0,127.0.0.1andlocalhostlisten as before. A client is reported in its own family, inREMOTE_ADDR, ASGI'sscope["client"]and the access log's newremote_addrfield, and an IPv4 client of::is reported as IPv4 (127.0.0.1), not as::ffff:127.0.0.1as gunicorn and uvicorn report it, so moving a server from0.0.0.0to::changes no address your application sees. Until now no IPv6 address could be listened on at all. For a Fly.io application,--host ::is what makes it reachable on the private network's.internaladdresses.ListenConfig.listenandNoTLSListenerdefault toNetworkType.tcp, the family the address names;Server.servetakes a listener of any network. -
Three rules of the event loop and of
--spawn-workersthat no gate held now each have one (SPEC A23, L16, E35). None changes what the server does.- A body timer that expires on a connection no longer reading its body is deleted, not only skipped. On Linux an expiry left registered is reported by every wait after it, and the loop spins.
- A WebSocket this side has closed keeps its two-second wait for the
peer's Close while its last frames go out slowly. Each send that moves
bytes would otherwise restart that wait as
--idle-timeout. - CI serves
--workers 2 --spawn-workersfrom a copy of m0serve under a directory namedjosé, the one shape that reaches the fix that lets a binary under a non-ASCII path re-exec itself.
Changed
-
The application layer's milestone is met.
unotes, the layer's first application outsideapps/, is recorded indocs/REAL_APP_VALIDATION.mdagainst 1.8.0: 1,748,962 responses at two workers and 864,732 at two loops on threads, each compared byte for byte with the same binary's answer alone, 0 failures, through six restarts and 9,722 abandoned responses, with RSS flat. It is a dogfood application and the record says so. -
The other six sabotage harnesses run on
scripts/sabotage_lib.py(sabotage-blobs,-host,-notes-login,-m0-wheel,-scaffoldand-mojo-image): a SIGINT or SIGTERM mid-rule now puts every file back, ends the gate's process group, removes the scratch directory and dies by the signal, where it used to leave the sabotaged source in the tree. Every anchor must match exactly once, a sabotage that only breaks a build is a miss, and each gate must pass on the unsabotaged tree first.sabotage-outbox-cap's give-up rule now breaks the claim alone. -
poe build-serveskips the link whenbin/m0serveis already current. Every smoke that serves through m0serve builds it first, so a run of several smokes relinked an unchanged binary each time, 4 s on an Apple-silicon Mac and about 8.5 on a CI runner. The build now writesbin/m0serve.stamp, a digest of everything it read (its arguments, every source under its include roots, the.mojocartifacts, the toolchain and what is installed beside it, the task itself and the scripts it runs) and of the binary and runtime it left, and a later call whose digests all match saysbin/m0serve is up to dateand stops, in under half a second. Any change, a missing binary or stamp, or a build that failed rebuilds; delete the stamp to force one.poe check-serve-stampproves each input rebuilds. -
poe smoke-doctortakes half as long. To tell a configuration the server accepts from one it refuses, it waited a fixed 8 s for each of the seven that serve. It now polls until the server answers and allows it 2 s more, so a server that answers and then dies is still read as its exit code, and a new control row, an application that answers once and then exits 3, fails the smoke if the watchdog ever reads it as served. The task went from 70 s to 33 on an Apple-silicon Mac. -
No gate binds a fixed port.
sabotage-scaffold,sabotage-outbox-cap,autobahn, the three browser checks and the executed quickstarts take a free port each run, so two runs on one machine no longer collide, andpoe check-task-shellsrefuses a fixed port in any task but aserve-*one.poe smoke-wheelproves the installed m0serve runs on the runtime its wheel ships by asking the loader which file it loaded: it used to remove that runtime and let the loader abort the process, which on macOS wrote a crash report every run. -
poe autobahnresumes a section, up to three times, when the container could not connect (SPEC I13). The suite's client stops at the first case it cannot connect for and still reports success. Under colima that happens to about 1 connect in 300, whatever server answers, so one section-6 run in three was cut short and the run failed with no cause given.- When the client printed
Connection to ... failedand the server still answers, the runner now runs the cases that section did not reach, on the same server, and says so. Each resume makes fewer connects than the attempt before it. - Across the attempts each of the section's cases must be scored exactly once. A thin section with any other cause fails the run, and so does one still thin after three resumes.
- Every verdict any attempt scored is judged.
- The client's output and the server's log are printed and kept under
bin/logs/autobahn/.
- When the client printed
-
Each m0serve worker says when a SIGTERM will drain it:
[worker N] pid=P armed for a graceful stop, the line the Mojo host's workers print, in every process shape, and before the🔥 m0serve:banner, which used to come first. A worker catches SIGTERM only once it has imported the application, so one worker answering says nothing of the others: a script that stops the server soon after starting it should wait for this line from every worker, or a worker still importing dies of the signal instead of draining. -
poe test-httpcompiles m0-http's tests once, not once per file, andtest-core,test-datastarandtest-wsgido the same. Each task ranmojo runon every test file, and each run compiled the package from source again:test-httptook 459 to 579 s of CI's coldunit-testsjob on ubuntu, 7 s of it running tests.scripts/mojo_suite.pybuilds a package's test files as one program and runs it once per file, so each file still runs in a process of its own. Beside the build it checks each file on its own withmojo doc, so an error in code no test calls still fails the task. It prints each file's count of tests run beside the tests the file defines, and fails on any difference, a failing test, a death by signal, or a file that does not compile. Cold on a busy Apple-silicon Mac,test-httpwent from 390 s to 66-107 s.poe sabotage-mojo-suitebreaks a small package ten ways and requires each to fail. To run one file,mojo runit as before. -
CI installs the second libsqlite3 its one-image gates need on macOS, and those gates fail without it (SPEC O22, O23). The tests that refuse a second image, and the sabotage that drops the pin's kept handle, need on macOS a libsqlite3 outside the dyld shared cache: Homebrew's. The runner image happened to carry one, which its software list does not promise. The macOS
unit-gatesleg and the nightly canary's now runbrew install sqlite, and underCItest_one_image.mojofails where it used to print that an arm was not exercised. On a developer's Mac without Homebrew's SQLite it still prints and passes. -
poe smoke-rampgates on the second-worst of its 24 samples, and says where the worst one spent its time (SPEC N20). Each placement arm required the WORST of 24/x/nowsamples under 100 ms, and a macOS runner stalls a single sample for 80 to 120 ms, twice in about 2,000. Over 40 greenTestsruns the host's full-lane arm measured 2 ms at the median and 8 ms at most on macOS (1 ms at most on Ubuntu), the host's one-loader arm reached 79 ms once and m0serve's 42 ms, and then run 36898942895 failed at 120 ms on a change that did not touch it, beside arms that measured 2 and 4 ms. A redSmoke test one views module on two hostson macOS with one slow sample was that, and a rerun passed.- The three bounded arms now read
second_now_ms, and the negative arm (the host with no lane) must still reach the bound by the same number. What the gate is for still fails it: with/x/nowtaken off the loop the second-worst sample was 132 to 194 ms in 13 runs of 13, since a request that waits for a pool thread is slow on many samples, not one. - Each result line carries
worst_now_msas before,second_now_ms, andworst_connect_msandworst_request_msfor the worst sample, so the next stall says whether it was in the connect or in the request. A failure prints the whole line. - CI records the gated numbers against the limit
(
ramp.second_now_ms.m0serve,ramp.second_now_ms.host,ramp.full_lane_second_now_ms.host), keeps the worst samples under their names with no limit, and adds the worst sample's two parts for the host's full-lane arm.ramp_probe.py selftestruns first: one stall in 24 must pass, two must not.
- The three bounded arms now read
-
poe smoke-hostandpoe smoke-host-threadsgate their placement arms the same way (SPEC E26, E27).host_probe.py placementprinted the worst of 24/healthsamples and nothing else, andsmoke-host-threadsfailed on it twice on macOS on 2026-10-01, at 120 to 190 ms, on changes that did not touch it. The probe now printsworst_health_ms,second_health_ms,worst_connect_msandworst_request_msthrough the functionramp_probe.pyuses (nowprobelib.placement_summary), the pool arms require the second-worst under 100 ms and the bare-loop arm requires it to reach 100, and a failure prints the whole line. With the gate app's health path moved to a pool thread the second-worst was 141 ms, so the rule the arm guards is still caught. CI recordshost.pool_second_health_msagainst the limit and keepshost.pool_health_mswithout one. -
A stream's socket has TCP keepalive on (SPEC I32), so a client that vanishes from a quiet stream no longer holds its connection for as long as the server runs. A closed laptop or a dropped network sends no FIN, and a stream has no deadline; the server noticed such a client only when something sent to it went unanswered. That left out every stream with nothing in flight: an SSE stream an ASGI application writes itself (a Starlette
StreamingResponse, FastHTML'sEventStream) while it has nothing to send, a WSGI body streamed from a handler thread, and any stream onceM0_SSE_HEARTBEAT_MSis0. Now the kernel probes a stream's client after 15 s of silence, every 15 s, and closes the connection at the third probe left unanswered, and the application is told its client has gone. Measured on Linux with a client whose packets were dropped: a quiet ASGI stream was still held 30 s later without it, and is closed 61.5 s later with it (4.1 s with the setting at 1). The probes carry no data, so no stream's bytes change, and a client that is idle or reading slowly answers them and keeps its stream.M0_STREAM_KEEPALIVE_Ssets the seconds, for the idle time and the interval alike;0turns it off. A stream the server heartbeats is reaped as before, when the heartbeat goes unanswered for as long as the kernel retries (about 16 minutes on Linux at its defaults): keepalive does not shorten that.
Removed
-
The last upstream
lightbug_httpnames nothing used:NetworkType.udp4andudp6, the free functionsis_ip_protocol,is_ipv4andis_ipv6(theNetworkTypemethods of those names stay), theTCP4SocketandTCP6Socketaliases,O_ACCMODE, and the argumentConnectionState.reading_bodyignored. Nothing served changes; an application built with them0wheel that named one needs its own copy. -
Framework names nothing in the tree used, from the packages the
m0wheel ships (DECISIONS D54). Nothing served changes:m0serveand the Mojo host reached none of them, andM0_API_KEYwas read intoAppConfigand never checked, so no request was ever refused by it. An application built with them0wheel (a0.xpreview) that imported one of these needs its own copy:m0_http.RequestContext;m0_http.check_api_key,AppConfig.api_keyandM0_API_KEY, whose capability, SPEC G9, is nowout of scope;m0_http.ResponseCache;m0_http.PatchJournalandJournalResult(DatastarStream's own replay journal stays);negotiate_encoding,negotiate_language, and the free functionswants_htmlandwants_event_stream, whereparse_accept(...).wants_htmlis the same answer; and m0-core's FNV-1a and xxHash32 (fnv1a,fnv1a_step,fnv1a_batch,xxhash32,xxhash32_batch) withformat_hash32.libm0coreexportsm0_shared_fetch_addalone, the callm0pubmakes:m0_fnv1a,m0_xxhash32andm0_format_hashare gone.m0_sqlite.stats_ints,sum_ints,min_ints,max_intsandColumnStatsleft the package,stats_intsliving on inbench_sqlite.mojo, its one user; andpoe bench-corewent with the m0-core benchmark it ran, which no longer compiled. -
The Mojo HTTP client,
m0_http.Client, withpoe smoke-client(DECISIONS D55). No application called it, it spoke no TLS, and a call from a view on the loop blocked every connection the loop held. Nothing served changes. An application built with them0wheel that used it needs its own; a client comes back as a design of its own that answers TLS and where the call runs. SPEC M14 is nowout of scope. -
The
libm0corerelease assets. Releases no longer attachlibm0core-linux-x86_64.so,libm0core-macos-arm64.dylibor their.tar.gzbundles, andpoe bundle-ffiwent with them (DECISIONS D56). The library ships inside the m0serve wheel, wherem0pubuses it to number the events it publishes, so nothing changes for an m0serve user. Releases up to and including 1.8.0 keep their assets.
Fixed
- m0-sqlite opens one copy of SQLite per process (SPEC O23). On macOS
with
M0_LIBSQLITE3naming Homebrew's build, or any library file of its own, a process that closed all its connections and then opened another loaded a fresh copy of SQLite: the library was pinned withRTLD_NODELETE, which on macOS keeps an image mapped but forgets it once its last handle closes. Two copies on one database file is the corruption SQLite's documentation warns about — each keeps its own list of open files, so a close through one drops the locks the other holds — and here it took a statement outliving every connection while a new one opened the same file. The pin now keeps one handle open for the life of the process; Apple's library, in the dyld shared cache, and Linux were never affected by that. Two things change with it for every platform. The first libsqlite3 a process opens is the only one m0-sqlite will open: a library that is another image —M0_LIBSQLITE3changed mid-process, or a second path — is refused where it is opened, naming both files. And the library is openedRTLD_LOCAL, no longer in the loader's global scope, where on Ubuntu's build it captured the internal calls of any other libsqlite3 loaded later, which then could not open a database ("no such vfs"). What this cannot cover is a copy m0-sqlite did not open. CPython'ssqlite3module carries its own SQLite in the interpreters uv installs, so insidem0servea Mojo mount using m0-sqlite beside a Python application on the stdlibsqlite3backend is two copies of SQLite in one process: give each side its own database file. - m0-postgres maps each libpq it opens once, and keeps it out of the
loader's global scope (SPEC O24). On macOS,
a process whose last connection closed and which then opened another
loaded a fresh copy of libpq, and of the libraries it links, each time.
The library was pinned with
RTLD_NODELETE, which on macOS keeps an image mapped but forgets it once its last handle closes, so the next open could not find it: m0-sqlite's defect above, in the code it was copied from. Nothing faulted, and a result read after its connection still answered. The process grew by one mapping of libpq per reopen, which for a connection per request on one thread is one per request. The pin now keeps one handle open for the life of the process, and a later open of the same library is a comparison where it was a seconddlopen. Linux was never affected by that. On Linux the change is the scope: libpq was opened in the loader's global scope, where it captured the internal calls of any other libpq build loaded later. With Ubuntu's libpq 16.15 loaded first and psycopg-binary's bundled 18.6 second, 69 of the second's symbols bound into the first, and a connection attempt through the second's own handle crashed the process. A Python application on psycopg-binary beside a libpq loaded that way, which is the arrangement underm0serve --pg-listen, was captured too: it reported libpq 16.15, not its bundled 18.6 (measured underm0serveitself, where every query still answered and nothing crashed). Every handle isRTLD_LOCALnow, and the two libraries keep to themselves: psycopg reports its own 18.6. Unlike m0-sqlite, a second libpq file is not refused: each one a process opens is pinned once. A library that opens and is not libpq is no longer left mapped after it is refused. - A closed
Socketrefuses every call, not onlyclose()(SPEC D1). Afterclose()a socket went on passing its old number to the kernel, so a latesendwrote into whatever the process had opened on that number since, areceivetook its bytes, ashutdownended its connection,bind,listen,connector a socket option acted on it, andinto_fdgave it to an owner that would close it.close()now leaves the socket holding -1: those calls fail with EBADF, as they would on a number nobody holds,shutdowndoes nothing, andinto_fdreturns -1. Nothing in m0serve or the Mojo host uses a socket after closing it; a Mojo application that does no longer reaches another descriptor.test_socket_close.mojogates each call. Found in review. - A thread busy with a WebSocket's messages at shutdown still stops.
When every thread of a lane was inside a view at SIGTERM while inbound
WebSocket messages filled that lane, the stop signal for a thread that
reads the lane directly could be refused for want of room and was
dropped. That covers the asyncio executor, and pool threads under
M0_POOL_ELASTIC=0orM0_POOL_RING=0. The thread finished its view, took the messages and waited for work for good: m0serve gave up on it after the 5 s join and exited naming a thread "still inside the application". The stop now waits for the lane to have room, inside that same 5 s. - A fast request that meets a busy handler thread no longer waits a
millisecond for the loop to notice (SPEC E37). With
--blocking-threads, a Mojo mount or the Mojo host'sM0_BLOCKING_THREADS, a request that arrives while one pool thread is inside a slow view wakes no second thread at once, because the busy one usually comes back first; the loop wakes a sibling once the request has waited long enough. At low traffic the loop checked that only once a millisecond, so such a request took about 1.5 ms where 0.1 ms was the answer with no slow view in flight. The loop now waits exactly until the request's wait runs out (10 µs on a Mojo lane, 200 µs on a WSGI lane) and wakes the sibling then: measured on an Apple M4 at 0.10–0.14 ms on a Mojo pool and 0.43–0.56 ms on a WSGI pool, against 1.45–1.55 ms before. Throughput under load is unchanged. On Linux the shorter waits needepoll_pwait2(kernel 5.11 or later); an older kernel keeps the millisecond. - Under
--workers NorM0_WORKERS, a worker that fails while the server stops makes the supervisor exit 1, whichever worker it reaps first (SPEC D10). A worker catches SIGTERM only once it has started up — for m0serve, once the application is imported — and a stop that reaches one before then ends it at once. When that was the first worker the supervisor reaped, it passed the signal on and then waited for the rest without looking at how they exited: a sibling that crashed in its drain went unreported and the supervisor exited 0, and even a sibling's clean exit went unlogged. Each worker is now reported and judged as it exits, the same way under--reload. A worker the stop reached before it had started up is logged as stopped, not as a failure: it had not yet taken any request. Found when CI's shutdown smoke signalled a supervisor whose second worker was still starting. - Under
--workers NorM0_WORKERS, no connection is handed to a worker that is still starting up (SPEC E16). The worker that accepts a connection passes it to the least-loaded sibling, and a sibling still starting — for m0serve, still importing the application — looked idle, so it was handed connections it could not answer until its startup ended: with a worker held 3 s, 16 of a burst of 32 each waited the 3 s. A stop that reached that worker before it had started up ended it with those connections, closed unanswered while the server exited 0. A worker is now handed connections only once it serves; a respawned one is handed nothing until it has started again. Found in review. - Under
--workers NorM0_WORKERS, no connection is handed to a worker that has died and was not replaced (SPEC E16). The supervisor does not replace a worker once it is stopping, once it has used up its respawns (five crashes in a row, each within a second of its start, or ten times the worker count within an hour), or when the worker exited 0 or 78. The worker's siblings went on reading the load it last published, which for one killed while idle read as idle with no connections, so the least-loaded choice handed it about half of every burst: accepted, and never answered until the whole server stopped. With--workers 2and worker 1 killed until the supervisor stopped respawning it, 16 of a burst of 32 went unanswered, under--spawn-workers,--reloadand the Mojo host alike. The supervisor now marks every worker it reaps as gone, so no sibling picks it; a replacement is picked again once it has started. Found in review. - Under
--workers NorM0_WORKERS, a long-running server goes on replacing workers that crash now and then (SPEC E36). The supervisor stopped replacing crashed workers after ten times the worker count of crashes over its whole life, however far apart they were: at one crash a day on four workers, after about forty days. From then on each crash left the server one worker short, with no error, until none was left and it exited 1. The limit is now ten times the worker count within any hour, so a worker that keeps crashing is still given up on as soon as before, and the rule for a worker that cannot start (five crashes in a row, each within a second of its start) is unchanged. m0serve and the Mojo host share the supervisor; a--reloadrestart counts as no crash, as before. Found in review. - Under
--workers NorM0_WORKERS, a connection handed to a worker just before it died is closed when the worker is not replaced (SPEC E16). The worker that accepts a connection may pass it to a sibling, and one passed in the moment before that sibling died waited, accepted and unanswered, until the whole server stopped once the supervisor had used up its respawns. The supervisor now closes each such connection, so the client sees it close at once and can retry; none had been read. Found in review. - A
Socketwhoseclose()fails is closed all the same (SPEC D1). A close that failed with anything but EBADF, such as EINTR from a signal or EIO, raised with the socket still holding its number, although the kernel had already released it; a secondclose(), or the socket's destructor, then closed whatever the process had opened on that number since, possibly another thread's connection. The failure is still raised, and the number is never closed twice. Nothing in m0serve or the Mojo host closes a socket twice; a Mojo application's own sockets are covered. Found in review.
1.8.0 — 2026-09-29
Most of this release is what a review of the whole tree found on
2026-09-28. A Mojo server no longer dies when a client leaves while it
writes: on macOS one visitor closing a tab ended any Mojo host application.
Every response head the server writes refuses CR, LF and NUL, not only the
gateway's. A POST's body timer no longer closes an idle connection 30 s
later, or hands a handler thread's answer to the next client;
--body-timeout sets it. Under --workers N a connection passed between
workers is no longer lost to a worker that is leaving, to one out of
descriptors, or on macOS to the kernel's collector of descriptors in
transit. On Linux a client's reset gives its slot back at once, a
keep-alive request costs no epoll_ctl, and a request in flight at SIGTERM
is answered. An ASGI application that installs asyncio's eager task factory
is answered, and an M0_INVERTED server exits on SIGTERM. Every listen
wrote its address into a stack buffer of zero bytes, and no longer does;
and a realtime hold never lets M0-Channel reach a client, and refuses a
channel in the server's reserved namespace, which a client could otherwise
use to aim a server write at another socket. The served contract is
unchanged; m0serve gains one flag. The fork loses about 4,400 lines that
nothing used or that the event loop replaced, its typed socket errors among
them, which matters only to an application that imports them. The m0
wheel ships as m0 0.4.0, whose Login.from_env refuses an unset
PREFIX_SECURE.
Added
-
m0 0.4.0: this release's framework, for applications built withm0. What changed sincem0 0.3.0for someone writing an application:- What an application must act on:
Login.from_envrefuses an unsetPREFIX_SECUREwith exit 78 (N43, under Changed). Anauthproject from an earlierm0addsAPP_SECURE = "1"todeploy/fly.toml's[env]andAPP_SECURE=0where it runs locally;m0 newnow writes both (N45, under Fixed), andm0 doctornames the scaffold files an earlierm0wrote differently. - What an application that reaches into the fork or the storage libraries
may have to change:
Server.serveandserve_nonblockingtake their listener with^, since the loop now closes it; a socket error is oneSysError, and the per-errno types are gone; fork code nothing used is gone, the UDP path, the demo services and the blocking accept loop among it (under Removed); andSqliteLibandPgLibkeep their entry points infns, solib.errstr(rc)becomeslib.fns.errstr(rc). - A client that leaves no longer ends a Mojo server (A25): not the loop,
not a supervisor under
M0_WORKERS, and not themake, producer and pool threads that run before the loop. Every Mojo host application was exposed,m0 new's among them. - A response head is safe from request data a view puts in it: a header
carrying CR, LF or NUL is dropped for a Mojo view as it was for the
gateway (G1, G2),
reply.redirectpercent-encodes a control byte, and a Datastarredirectrefuses ajavascript:or off-site location (I29).redirectandpage_or_fragmentsend the standard reason phrase of every status that has one (under Changed). listen_and_serveandServer.serverun the event loop, so SSE and WebSockets work through them. A POST's body timer no longer closes an idle keep-alive connection 30 s later (A23). A request with twoHostlines is answered 400 (B10). A listen failure is reported in its own words, and a listen no longer writes its address into a stack buffer of zero bytes.- Under
M0_WORKERS, a supervisor signalled while it forks passes the stop on (D2), and a connection passed between workers is no longer lost (E16). UnderM0_THREADSon Linux, the Date header no longer mixes two seconds' fields. - Storage:
m0_sqlite'sINSERT ... SELECTfromm0_arrayinserted nothing since 0.3.0, and inserts again (O4).m0_postgresrefuses a NUL in atext()parameter (O10), keeps aResultalive whilerawreads it (O16), and connects with a URL that ends in?or&(O7); its binary mode reads like text mode for fewer types than O11 said. m0_core.json_parsereads a number by JSON's grammar:1.9is not an integer, and01is not a number.
- What an application must act on:
-
CI refuses the asyncio executor's state passed by value (SPEC L31). Mojo 1.1.0 copies a
mutargument of 256 bytes or less into the call and stores the copy back when it returns, which erased the two writes behind theM0_INVERTEDserver that never exited on SIGTERM and the eager task whose first-step answer was never sent (both under Fixed).poe check-copyback, part oftest-all, reads m0serve's LLVM IR and fails if any function takesExecutorStateby value, or holds it inside an argument that is, and on every run first shows it can fail on a control written the way that code was. A sweep of every other struct the tree reaches through an address found none that anything writes while a call holds a copy (docs/notes/mut-arguments-and-raw-addresses.md).
Changed
-
m0:Login.from_envrefuses an unsetPREFIX_SECURE(SPEC N43), a change to the framework an application built withm0must act on. Whether the session cookie carriesSecureis a fact about the deployment that the server cannot see behind a proxy, and read as off when unset it sent the cookie in clear on a visitor's firsthttp://request, before any redirect to HTTPS. It is now stated, and a server without it exits 78 naming it:1wherever the application is served over HTTPS,0over plain http such ashttp://localhost. What an application does about it:- One made with
m0 new --template auth: addAPP_SECURE = "1"todeploy/fly.toml's[env], andAPP_SECURE=0where it runs locally: the shell'sexport,smoke.sh, and the tests'setenv. A projectm0 newwrites now has all three (under Fixed), andm0 doctornames the scaffold files an earlierm0wrote differently. - Any other login: state
PREFIX_SECUREin each environment that starts it, the deploy's included.apps/fragment_notesreadsM0_NOTES_SECURE, andserve-fragment-notesdefaults it to0.
- One made with
-
listen_and_serveandServer.serverun the event loop, so SSE and WebSockets work through them. They were a blocking accept loop of their own, the one README's WSGI example calls. It served one connection at a time: a second client waited behind a first that held its keep-alive connection idle, up to the 60-second idle timeout. It answered every SSE or WebSocket response with409. And it had missed fixes the event loop carries: an upload over the body limit had its connection reset under it after a few hundred KB, where the loop reads and discards the rest and the client reads its413. Both now delegate tolisten_and_serve_nonblockingandserve_nonblockingwith their defaults, and ashutdown_read_fdgiven toServerends either gracefully.m0serveand the Mojo host never used them.test_sigpipe.mojoruns each until its closed shutdown pipe ends the loop, under a watchdog that fails the file rather than hang it. Found in review. -
m0_http.reply.reason_phraseis the whole standard table: CPython 3.13'shttp.client.responses, 62 codes.reply.mojokept two partial tables, 16 codes forpage_or_fragmentand the login and five forredirect, while m0-wsgi held the whole table for the ASGI path.redirect,page_or_fragment, the login's refusals and m0serve's answer to an ASGI application's integer status now read the one table, and the codes the small tables listed keep their phrases. Apage_or_fragmentstatus the old table did not list now carries its standard phrase instead of none, andredirectwith a status other than 301, 302, 303, 307 or 308 says what that status is (201 Created) rather thanRedirect. An ASGI application's status goes out as before.test_reply.mojoholds the table. -
SqliteLibandPgLibkeep their C entry points in one table,fns, which eachStatementandResultcopies whole (SPEC O16, O18). Code that called an entry point on the library itself now calls it on the table:lib.errstr(rc)becomeslib.fns.errstr(rc).path,open_library,PgLib.open,PgLib.libversionandPgLib.version_textare unchanged, and code that goes throughConnection,StatementandResultchanges nothing. -
A sabotage that does not compile is a miss, never a catch (SPEC F17).
sabotage-pool,sabotage-trailersandsabotage-keepalivecounted any failure of their gate as the rule being guarded, so a sabotage that only broke the build passed as proof;sabotage-fuzzandsabotage-outbox-capskipped one and still reported every rule guarded. All five now run onscripts/sabotage_lib.py. A catch needs the gate to have run: themojodriver's last line names the phase that failed, and a timeout or a crash that prints nothing is settled by building the sabotaged source alone. An anchor must match exactly once (sabotage-outbox-cap's give-up anchor matched twice and is re-pointed). A rule this platform cannot observe is reportedSKIPPEDand never counted as guarded. Ctrl-C or SIGTERM mid-run puts every file back, then ends the harness by that signal. Each task takes--onlyand--skip, andsabotage-keepalivenow checks its unsabotaged probe first. -
CI's jobs share one setup step and one measurement step. Every job in
test.ymlset up uv, installed its Debian packages, and rendered and uploaded its measurements with its own copy of the same steps. Each now calls.github/actions/setupand.github/actions/record-measurements.check-docsreads an action as part of each job that calls it, so a job that stops calling the record action is still named. So is awheel-consumejob that reaches uv through the setup action, which by this repository's reference needs no checkout. Dependabot now reads the actions' own pins, whichdirectory: "/"never did, andcheck-docsholds it to that. The check also compares the file a job'sM0_RESULTSnames with the file it uploads by whole name:results.jsonlused to pass asci-results.jsonl. -
CI's smokes are three jobs. The
smokejob had grown to a median of 21.4 min on the ubuntu leg, against a 30-minute cap that a runner 1.45 times slower, the slowest seen, would pass. It keeps the server's smokes and takes m0serve's command-line and--doctorsmokes fromsmoke-gateway. A new job,smoke-app-layer, takes the Mojo host and its applications, the m0 wheel and its scaffold, and MAX's runtime under both hosts. By the step times of the 15 green runs of 2026-09-29, the three come to 12 to 13.5 min on ubuntu, and each is capped at 25. -
poe test-shimproves each of the executor shim's rules with the test written for it (SPEC L7). Its sabotage reverted each of 55 rules and ran all 59 tests against it, 3,304 runs, and took a rule as proven when any test failed, even one written for another rule. Each rule now names its test, only that runs, and a rule whose own test passes is unproven whatever else fails, with the tests that did fail listed so the fix is plain. The task took about 490 s on CI's ubuntu runner and 820 s on macOS, and takes 26 s and 33 s; theunit-gatesjob went from 20.1 and 19.8 min to 12.7 and 9.4. Two rules whose tests no sabotage reverted gain one: a finished request releases its slot's state, and a socket's accept belongs to the socket, not its slot.--sabotage-allis the whole run, kept for re-deriving the tests when rules change, and--selftest, which the task runs first, shows on a real rule that a test that does not catch it, a test the suite does not list and a patch that does not apply each fail the run. -
poe test-shimguards five more of the executor shim's rules, each a line whose removal failed no test (SPEC L7). None changes what the server does: each rule now has a test written for it, and the sabotage reverts it.spawndrops the slot's disconnect future before a request's task exists. Without that, a request parked inreceive()on a slot recycled from a stream heard the stream's disconnect at once, and under Starlette'sStreamingResponsethe next client's stream ended as it began.- It drops it before
create_task, not after. Under an eager task factory, a request parked inreceive()in its first step otherwise lost its own disconnect future, and never heard its client leave. - A WebSocket's inbox belongs to its own task. Kept by slot, a socket
whose client left while it was not in
receive()passed that disconnect, close code and all, to the next socket on the slot. - A stream's send asks whether its client has gone before it reads the
slot's credit state. Without that, a push to a stream whose client had
gone, while the stream's task was still in a
finally, raisedKeyErroronce the slot's next request had finished and cleaned the slot, where it must be a quiet no-op. - A stream whose client leaves, and a socket, clean their slot's state when they finish. Without that, their credit windows, stream task and inbox stayed on the slot until its next connection.
-
CI's unit tests are two jobs.
poe test-allhad grown to 34-35 min of its job's 40-minute cap on the ubuntu leg. It is nowbuild-alland two halves,poe test-packagesandpoe test-gates, and CI runs each in a job of its own,unit-testsandunit-gates: about 20 min each on ubuntu at first, capped at 40. Withtest-shimdown to seconds (above),unit-testswas then every run's longest job, 20.6 to 23.8 min cold on the ubuntu leg, againstunit-gates' 12.6, so five tasks moved frompoe test-packagestopoe test-gates: the Datastar SDK's conformance and its sabotage, the probes' phase-stamp check, and m0-sqlite's tests and layout guard. Measured cold on the eight runs that followed,unit-testsran 10.4 to 17.5 min on ubuntu andunit-gates13.3 to 17.6, the two about even on runners of the same CPU, and each is capped at 35.poe test-allstill runs the whole locally, the same tasks as before, andcheck-docsrefuses a task thattest-allreaches and no CI step does. Both jobs keep Mojo's compile cache between a pull request's runs, which took a warm re-run ofunit-testsfrom 20.7 min to 9.2. -
Fifty-three probes share one library,
scripts/probelib.py, every probe but one. It holds the phase stamp,fail(), a server watched until it answers, a free port, an SSE reader and a WebSocket client, each proven bypython3 scripts/probelib.py --selftestin CI, andstamp()takes two options for the probes that needed them:fail_stream=, for a failure line that goes to stderr while the crash line goes to stdout, andecho=, for a probe that announces each phase as it begins. Among the probes moved: the WebSocket and SSE clients ofsmoke-chat,smoke-fastapi, the Django and Flask realtime smokes,smoke-idle-timeout,smoke-host,smoke-sim-loopandsmoke-todo; the raw-socket probes ofsmoke-shutdown,smoke-pipelining,smoke-early-413,smoke-body-timeoutand the other HTTP/1.1 smokes; the response-head and chunked keep-alive probes of the WSGI and ASGI smokes;smoke-ramp's andprobe-pool-fairness's; and two docker probes, and thepid1job's two. Each prints what it printed, on the stream it printed it on. Onlyapps/ws_echo/ws_probe.pystays inline, aspoe check-phase-stamps' example of that form.- A server that exits before it answers is reported at once, with its log:
smoke-exec-inheritandsmoke-child-publishused to wait 120 s for one,smoke-ws-inboundandsmoke-slot-lifecycle30 s, andstress-pool's two probes and the held server insmoke-host-doctoreach polled for up to a minute. outbox_cap_probe.pygives the connection one 20 s deadline, and the chat and realtime probes read a WebSocket against one deadline, where a per-read timeout was reset by every heartbeat. Sosabotage-outbox-cap's two rules that leave the connection open are caught by the probe's own verdict rather than by the harness killing it at 180 s.pool_fairness_probe.pywaits for its server through the library'swait_healthy: its own poller, answered with an empty body, asked again at once, about 10,700 times a second against a server that answers 101, where the library's asks 19.demo_probe.pynames a refused WebSocket upgrade; it used to die ofValueError('too many values to unpack').poe check-phase-stampsaccepts a probe that takes its stamp from the library and holds the library to the crash handler's rules. It now judges the unsabotaged tree before any sabotage, and requires every rule to have a sabotage that the rule itself catches.
- A server that exits before it answers is reported at once, with its log:
Removed
-
Upstream
lightbug_httpcode that nothing used, about 870 lines of the fork, found by the 2026-09-28 review. None of it was reachable fromm0serveor the application layer, but them0wheel ships the fork's source, so an application importing one of these names directly needs its own copy: the UDP read path (UDPConnection,Socket.receive_from,UDPSocket,UDP4Socket,UDPAddr);Socket.get_socket_optionandget_timeout;htonlandntohl; twenty-twoSocketOptionmembers nothing set, several with OpenBSD's numbers and so wrong on macOS and Linux, and everySocketTypebutSOCK_STREAM; theHTTPResponse.from_bytes(bytes, connection)overload;http_parse_headers;uri.Scheme; the five demo services inlightbug_http.service(Printer,Welcome,ExampleRouter,TechEmpowerRouter,Counter),WelcomeandCounteralso exported fromlightbug_httpitself;span_is_ascii;RequestBodyState; andListenConfig'skeep_aliveargument, which was stored and never read. NOTICE lists each. -
The fork's typed socket errors, replaced by one
SysError, about 2,960 lines. Each function inlightbug_http.c.socketraised one of 109 types, one per errno (SendEAGAINError,BindEADDRINUSEError, ...), through 14 variants (SendError,AcceptError, ...), and returned normally for an errno none of them named:socket()could hand back -1 as a descriptor, andlisten()on a connected socket reported success. Each now raiseslightbug_http.c.socket_error.SysErrorwhenever its call fails, naming the call and carrying its errno, withwould_block(),interrupted(),connection_aborted()andaddress_in_use()for what the server asks of one.m0serveand the Mojo host serve as before, and a listener they cannot make is reported with the call and its errno, such asbind: Can't assign requested address (errno 49)on macOS. Them0wheel ships the fork's source, so an application that catches one of these types, or names one, needs to change: every per-errno type and variant;FatalCloseError,SocketAcceptError,SocketConnectError,SocketRecvfromError,InvalidCloseErrorConversionError,CreateConnectionError,BindFailedError,SocketCreationError,ListenFailedErrorandChunkedEncodingError;SocketGetsocknameError, nowSocketNameError; andSocket.accept,NoTLSListener.acceptand theacceptfunction, which nothing called (the event loop usesaccept_with_peer). NOTICE lists each. Found in review. -
The blocking accept loop's own code, about 560 lines of the fork:
handle_connection,gate_streaming_response, andStreamingUnsupported, the409it answered a stream with. Them0wheel ships the fork's source, so an application calling one of them directly needs its own copy;listen_and_serveandServer.servestay, and run the event loop (under Changed). NOTICE lists each.
Fixed
-
Listening no longer writes past the end of a stack buffer. Every listen -- m0serve's, the Mojo host's,
Server.listen_and_serve-- binds its socket throughinet_pton, which converted the address into a buffer of zero bytes: it was counted inc_voids, andc_voidisNoneType, whose size is 0. The four bytes of the address (sixteen for IPv6) landed on whatever the stack held beside it. What that damaged depends on how the compiler laid out the caller's frame: no crash is known in a released server, and a test build in review crashed inListenConfig.listenwith SIGSEGV. The buffer is now counted in bytes, andpoe check-zero-alloca(intest-all) refuses a zero-size stack buffer that anything could write through, anywhere in m0serve. Found in review. -
A client that leaves no longer kills a Mojo server (SPEC A25). A built Mojo binary kept SIGPIPE's default action, which ends the process, and the kernel raises SIGPIPE when the server writes to a connection its client has reset: a visitor who closes the tab before the page comes back, a download abandoned halfway, an SSE subscriber going away. On macOS one such client ended a fresh server every time, with status 141. Every Mojo host application was exposed (
apps/blobs,fragment_notes, anythingm0 newscaffolds), as was a server built onServerdirectly. UnderM0_THREADSthe whole process went; underM0_WORKERSthe supervisor respawned the worker, which read as churn. The event loop, whichServer.servenow runs too (under Changed), ignores SIGPIPE before its first send, so the write fails and only that connection closes. m0serve's serving processes were not affected: the CPython they embed already ignores the signal.smoke-hostsends a built serverkill -PIPE, then a client that resets before its answer, on both CI legs. -
Under asyncio's eager task factory, an ASGI stream is stopped when its client leaves (SPEC L30). An application that installs
asyncio.eager_task_factoryruns each request's first step before the server has recorded which task serves the request, and a response that began streaming in that step was marked on the wrong task.- If an earlier request's task was still running on the same connection slot, the stream was marked on that task. That covers a keep-alive connection's previous request finishing its background work, and a connection that had just closed. The new client leaving then cancelled the other task, cutting the earlier request's background work short, and the stream ran on until the server shut down.
- If the body came from a child task, as Starlette's
StreamingResponsesends it, the stream was marked on the child. When the client left, the request was reported as failed after its response had begun.
-
A supervisor, and what an application runs before its loop, survive SIGPIPE too (SPEC A25). The ignore above arrived with the event loop, which a supervisor never enters: m0serve's under
--workersor--reload, and the Mojo host's underM0_WORKERS.kill -PIPEended either with status 141 and left its workers serving, orphaned (measured on macOS). m0serve's supervisor forks before any Python call, so CPython's own ignore never reached it. A Mojo host application'smake, producer and pool threads also run before the loop, so a write there to a peer that had gone ended the server before it served (one worker, orM0_THREADS), or killed worker 0 on every respawn (M0_WORKERS). The listener now ignores SIGPIPE first: both hosts bind before they fork or start a thread, so every process and thread they run inherits it.smoke-hostsends the supervisorkill -PIPEand has a producer'smakewrite to a closed pipe;smoke-spawn-workerssends m0serve's supervisorkill -PIPE. -
A response header, reason phrase or
Set-Cookieline carrying CR, LF or NUL is refused on every path, not only the gateway's (SPEC G1, G2). The check lived in m0-wsgi, so a head built in Mojo -- a view's, the Mojo host's, a--mount X=mojopool thread's -- was written uninspected, and a view that put request data in a header could end its own head and add headers, or a body, of its choosing:reply.redirect(303, next)withnextfrom the query, whichunquotehas already decoded from%0D%0Ato CRLF. The server's head writer now drops such a header or cookie line and sends such a reason phrase empty, for every response, as m0-wsgi did for an application's head. On an eight-header head the writer measured within about 10 ns of the old one. -
M0-Channelno longer reaches a client, and a hold on a reserved channel is refused (SPEC G18). Under--realtimethe server consumes theM0-Hold/M0-Channelinstruction headers before it holds a connection, but it returned early whenM0-Holdwas absent — so a response carrying onlyM0-Channel(a leftover header, or anM0-Holdthe server dropped for carrying a control byte) sent that internal instruction header on to the client.M0-Channelis now stripped from every response, whether or not a hold is taken. And a hold whoseM0-Channelnames the reserved\x01control namespace is now served as an ordinary response rather than held, the same as a hold with no channel. That namespace is how the server addresses one connection's slot on its event loop, and every publish path already refuses it: an application that builds its channel from request data, such as a room name from a form field, in which%01decodes to that byte, could have let a client aim a server write at another client's socket. Applies to a WSGI view's hold, a Mojo mount's hold and--mount PREFIX=hold. -
reply.redirectpercent-encodes a control byte in its target (SPEC G2). A target built from request data, such as?next=%0D%0A..., whichunquotedecodes to a real line break, carried the break into the response head, where it could end the header and start one of the request's choosing. Every C0 control byte and DEL in the target is now percent-encoded, asurl_forencodes one, so the redirect still goes where the view meant; every other byte is written as given, so an ordinary target is unchanged.test_reply.mojoholds both, on the header and on the head's bytes. -
A Datastar redirect no longer sends a location that runs script or leaves the site (SPEC I29).
redirectandDatastarStream.redirect_toassign the location towindow.location, which runs ajavascript:URL in the page's origin and follows//evil.exampleoff the site; anext=parameter after a login is how either arrives. Both now raise unless the location is anhttp/httpsURL or a reference relative to the page (/path,path,?query,#fragment) that stays on its site. The location is read as the browser reads it, so the spellings a browser also takes for those are refused too: a leading space, a tab inside the scheme, the scheme in capitals,/\evil.example. A redirect off the site still goes out when it names its scheme. Found in review. -
A
Set-Cookievalue's bytes above 0x7F reach the wire as the application gave them (SPEC G17). Every other header goes out in ISO-8859-1, as PEP 3333 and RFC 9110 §5.5 have it, but cookie lines were written as UTF-8: a WSGI application'scaf\xe9went out ascaf\xc3\xa9, and an ASGI application's own bytescaf\xc3\xa9as the double-encodedcaf\xc3\x83\xc2\xa9. Cookie lines now take the same latin-1 writer as every other header. A cookie that is all ASCII, as Django's and the session cookiem0_http.sessionbuilds are, is unchanged. -
A request carrying two
Hostlines, or twoTransfer-Encodinglines, is answered 400 (SPEC B10, B11). The parser kept the last line of a repeated field and served the request, so a proxy that routes on the firstHostand an application that reads the last (Django'sHTTP_HOST) disagreed about which site the request was for; RFC 9112 §3.2 requires a 400 for more than oneHostline in any request. TwoTransfer-Encoding: chunkedlines meanchunked, chunked, which was refused on one line and accepted on two. A second line of either is now refused whatever it says, as a secondContent-Lengthline already was. -
A POST no longer leaves a timer that closes its connection 30 seconds later, or sends a pool thread's answer to another connection (SPEC A23). The body timer was armed for every request with a body and left running when the body arrived with the headers, as a small POST's does. When it fired it closed the connection whatever it was doing: an idle keep-alive connection was dropped, and one whose next request was on a
--blocking-threadsthread (a WSGI app gets them by default) had its slot released under that thread, so the next client to connect read the answer meant for the first. The timer is now armed only for a body still arriving, and acts only on one.--body-timeout SECONDS(default 30, 0 = never) sets the deadline, and--doctorreports it. Two deadlines around it changed too (A4, A24): a request that starts late in the keep-alive window is no longer cut at the previous response's deadline, as an upload begun 7 s into a 10 s--idle-timeoutwas at 10.2 s; and a response the client stops reading is closed once--idle-timeoutpasses with no send making progress, where it used to hold its connection for good. A response read slowly but steadily is not affected. -
A client that resets its connection no longer holds its slot on Linux (SPEC C9). epoll reported a socket error as a failed registration, which the event loop skips, so a client's RST was never seen: a response stalled on a full socket, or a keep-alive connection sitting idle, kept its slot and descriptors for the life of the process unless
--idle-timeoutreaped it, and every graceful shutdown then waited out its full 5 s drain. The reset now closes the slot at once, as it always did on macOS. Two sends that stop part-way are finished too. A--staticfile whose response head the socket could not take at once is now sent after it (J10): the file was skipped, so a client pipelining requests read the next response's head where the body belonged. And a WebSocket ping or Close answered while the send buffer toward the client is full goes out whole once the socket drains (I31), where the reply was cut, misframing every frame after it, or dropped when refused whole. -
A client that stops reading no longer keeps the event loop at a full core on macOS (SPEC C10). A connection waiting for its client to take a response, or whose request was out on a handler thread or the ASGI executor, stayed registered for reads, and macOS reports an unread event on every wait: a client that half-closed, or sent its next request, and then stopped reading held the loop at 100% CPU for as long as the response waited or the view ran. Such a connection is not watched for reads until it can read again, on both platforms, and what the client sent is still answered then. With
--access-log, a stream or WebSocket whose frames did not go out in a single send logged a record for each such frame, and a chunked stream one more at its end;--metricscounted each as a response. A stream is one record now, written when its head lands (F18). On Linux, a WebSocket whose incoming messages had been paused for the application could stop sending for good if the pause lifted while a frame was still going out. And a WebSocket the server closed itself, answering a message too large for the handler pool with 1009, could be held until the process exited when its client never answered the Close; it is closed after the 2 s linger. -
A keep-alive request, and each read of an upload, no longer cost two
epoll_ctlcalls on Linux (SPEC A13). Since 0.13.0 the event loop re-registered a connection's read interest after every read, of the headers and of a request body alike, which the kernel refused as already registered before accepting the modification behind it: two wasted system calls per request, the pair 0.4.0 had measured out of the hot path, and two per read of an upload arriving in pieces. Only a read that fills the buffer, or the client's EOF, now re-registers; the rest of a large request is still read at once, a 1 MB body sent at once is still read without a stall, and one the client cuts short with a half-close is still closed at once rather than at the body timeout.smoke-large-requestcounts the calls understraceon Linux: 4 over 2000 keep-alive requests, where the old loop made 4004, and 3 over 101 reads of a body sent in 100 pieces, where it made 201. macOS paid onekeventa request, and one a read, for the same reason, and no longer does. -
On Linux, a connection that fails as it is accepted no longer holds up the ones queued behind it. Linux's
acceptcan return a network error already pending on the connection it takes off the queue, such asEPROTOorEHOSTUNREACH, and says to retry. The event loop stopped taking connections for that pass onEPROTOandEOPNOTSUPP, and the listener announces only new arrivals, so clients already queued waited for another connection to arrive; the six others reached it as a descriptor of -1, which it failed to admit and skipped, until the socket errors became one error (under Removed) and they stopped the pass too. All eight now cost only their own connection, as a client that gave up while queued always has.test_socket_errors.mojoholds the list. Found in review. -
A request in flight when SIGTERM arrives is answered on Linux, not left to the drain's deadline (SPEC D1). The event loop reads the stop as one of the events that are ready together, and it stopped handling that batch at the stop. On Linux each of the others is reported only once -- a request's bytes, the answer a handler thread or the ASGI executor has just finished, a response's next write -- so one that came in beside the stop went unanswered, and the drain waited out its 5 s before the process exited. A request whose handler raised SIGTERM on the ASGI executor met it in 20 of 32 tries with the server on one CPU. On macOS the same could strand a response waiting to send the rest of itself, and the application's tick. The loop now handles the whole batch, and admits no new connection in it.
test_shutdown_pass.mojoputs each kind of event behind the stop, on both platforms, andsmoke-asgirepeats the request on Linux with the server pinned to one CPU. Found in review. -
A server's listener is closed once, by its drain (SPEC D1). The event loop closes its listener as the drain begins, and every owner of a listener closed it again once the loop returned:
Server.listen_and_serveandServer.serve, the Mojo host, and m0serve with and without a handler pool, under--spawn-workersshutting the adopted listener down first. By then the number had been free for as long as the drain ran, and a new descriptor takes the lowest free number, so it had usually been given to something else -- in a Linux container, aprintduplicating stdout on the loop thread, and the.pycfiles pool threads were importing. The second close then failed, or closed a file or socket that was not the owner's; the Mojo host made it before joining its pool and producer, whose threads may still be working. The loop now owns the listener it is given and closes it once, an error on the way out included, and no owner keeps a copy. A Mojo application that callsServer.serveorserve_nonblockingwith its own listener passes it with^(server.serve(listener^, handler)), since the loop closes it.test_listener_owner.mojoholds each owner's number past its return on both platforms, andsmoke-shutdowntraces m0serve's under strace on Linux. Found in review. -
A closed
Socketleaves its descriptor number alone (SPEC D1). A socket made from a descriptor it was given (Socket(fd=...)) is born connected, andclose()did not mark it otherwise, so destroying it afterclose()shut down whatever the process had opened on that number since -- a new descriptor takes the lowest free number -- and a secondclose()closed that descriptor outright. Nothing in m0serve or the Mojo host closes such a socket and keeps it since the listener fix above; a Mojo application that adopts a descriptor into aSocketcould.close()now marks the socket unconnected, and does nothing on a socket already closed.test_socket_close.mojogates both. Found in review. -
The Date header is right with loops on threads on Linux. Every event loop formats its own Date header, once a second, and did it through libc's
gmtime, which on glibc returns one buffer for the whole process. A Mojo host application underM0_THREADS, or m0serve under--threads, runs its loops as threads of one process, so a loop could read fields another loop had just written, and at a day's or a year's boundary send a Date mixing two seconds' fields. The formatter now fills a buffer of its own (gmtime_r). macOS was not affected: itsgmtimekeeps a buffer per thread. Found in review. -
A supervisor signalled while it is still starting its workers passes the signal on instead of dying and leaving them running (SPEC D2). Under
--workers N, and in a Mojo host application withM0_WORKERSabove 1, the supervisor installed the handler that passes SIGTERM and SIGINT on to its workers only after it had forked the last one. A worker answers as soon as its loop starts, which can be before the next worker is forked, so a stop sent to the supervisor's PID alone in that moment (by a process manager or a deploy script, say, as soon as the server answered) took the default action: the supervisor died, no worker was signalled, and every worker already forked went on serving and holding the port. The supervisor now installs the handler before its first fork and signals each worker as soon as it has its PID; told to stop while forking, it forks no more workers and waits for the ones it has; and a worker the stop reaches before it has set up its own signals leaves at once rather than serving on.smoke-shutdowngates it by holding the supervisor between its first two forks and signalling it there, and the phase that caught it in CI, once in about a hundred runs, now also checks the supervisor's exit status and every process in the server's group rather than only the worker PIDs in the log. Found by CI. -
A worker draining under
--workers Non Linux no longer takes a connection for the listener (SPEC D1). A worker's drain closes its reference to the shared listener, but on Linux epoll goes on watching a descriptor that another process still holds open, and the supervisor and every sibling hold the listener. So the draining worker kept waking for connections waiting for its siblings to accept them, and the next descriptor it opened took the listener's freed number. When that was a connection a sibling handed over during the drain, the rest of its request was read as activity on the listener and never answered: the client was reset when the drain's 5 s ran out (measured in a Linux container, with the hand-off delayed into the drain). The drain now stops watching the listener before it closes it, and forgets the number.test_drain_listener.mojogates both steps, and on Linux the kernel's side. Found in review. -
A connection accept sharing passes to a worker that is shutting down is answered (SPEC E16, D1). Under
--workers Nthe worker that accepts a connection may pass it to a lighter sibling. When that sibling was stopping on its own, as when one worker is sent SIGTERM, the connection could be lost two ways. A worker with nothing in flight exits within about 50 ms, and a connection passed to it just after it had checked for them waited in a channel nobody would read again: its client got no answer, and was reset only when the whole server stopped. A connection that reached a stopping worker before its client had sent a byte was closed within 2 ms as idle, and its client read EOF. Now the worker passing a connection checks again, after counting it, whether its sibling has begun to stop, and keeps the connection if it has; a stopping worker waits, within its 5 s drain, for any connection counted to it; and a stopping worker passes what it receives on to a sibling that is still serving, keeping it only when every sibling is stopping. A worker restarted after a crash no longer inherits the count of connections that died with its predecessor. That count made its siblings pass it fewer connections for the rest of the server's life, and with the wait above it would have held each of its shutdowns, and each--reload, to the full 5 s. Both losses were reproduced on Linux and macOS with the hand-off delayed on purpose.test_handoff_leaving.mojoforces each order of events. Found in review. -
Under
--workers Non macOS, a connection passed between workers is no longer lost when another process closes a local socket (SPEC E16). Accept sharing passes a new connection to a less busy worker over a Unix-domain socket. macOS's kernel runs a collector of descriptors in transit whenever any process on the Mac closes a Unix-domain socket, and it flushed every passed connection it found in transit: the request the client had sent was thrown away, and the worker that received the connection read nothing and closed it, so the client got an empty reply or a reset. With one other process opening and closing Unix-domain sockets, 179 of 1,600 requests made in bursts of 32 against--workers 2failed this way, about half of the 361 passed between workers; none fail now. The server now holds each worker's channel in a way the collector follows. Linux was never affected. Found in review, when the macOS CI runner failed two accept-sharing tests. -
--workers Nno longer lets a worker's load read one connection high for good (SPEC E16). Since 0.18.0 the worker that passes a connection to a sibling counted it in flight only after sending it, so a sibling that admitted it and finished its pass first found nothing to retire, and the late count then stayed: that worker looked one connection busier than it was to every accept after. The count now goes up before the send, and back down if the send fails. -
Under
--workers N, a worker at its open-file limit no longer strands the connections passed to it (SPEC E16). A connection one worker accepts and passes to a sibling travels as a descriptor, and a sibling with no descriptor free cannot take it: the kernel closes that connection and delivers the message without it, on Linux at once and on macOS after one failed receive. The sibling read that as an empty channel and stopped admitting, and the channel only announces new arrivals, so the connections queued behind it waited, their clients connected and unanswered, until another connection was passed to that worker; and the lost one stayed counted as in flight to it, so every accept after read that worker as a connection busier than it was. It now skips the lost one, admits the rest and retires its count.test_accept_share.mojogates it. Found in review. -
A malformed accept-sharing hand-off no longer leaks its connection (SPEC E16).
recv_fdrefuses a message whose control data was cut short, and its check never saw one. Linux'sMSG_CTRUNCis 0x08, and the 0x20 it tested there isMSG_TRUNC, set when the payload is cut short. macOS'sstruct msghdris 48 bytes, not the 56 assumed, so its flags were read from a word the kernel never writes. A refused message also closed nothing, though the kernel installs each passed descriptor as the message arrives. So on Linux a hand-off whose payload was cut short lost its connection, left open for the life of the worker with its client waiting, and on both platforms a message carrying extra descriptors was taken in part, the rest left open. Neither shape comes from the server's ownsend_fd, which passes one descriptor and a payload that fits. The flags are now read where each kernel writes them, a truncated control message is refused with every descriptor it delivered closed, and a payload cut short keeps its descriptor.test_accept_share.mojosends both shapes and checks that nothing is left open. -
--spawn-workersworks for a binary installed under a path that is not ASCII (SPEC E35). The running binary's path was rebuilt a byte at a time as characters, so each byte above 0x7F became two: under/Users/josé/every worker's exec failed with "No such file or directory" and m0serve refused to serve (exit 78). The path is now the bytes the operating system returned. -
An
M0_INVERTED=1server exits on SIGTERM wherever the signal lands (SPEC L8). Under the loop inversion a SIGTERM is read by whichever pass of the event loop reaches the shutdown pipe first. When that was the pass a completion flush runs, the drain began -- the listener closed, streams told goodbye -- and was then lost: the flush had taken the executor's state as a copy before its pass and stored the copy back after it, so the record that the drain had begun was erased, no later pass saw the stop, and the process served nothing and never exited;docker stopended it with SIGKILL. CI's macOS runners met it in about 2 of 100 runs ofsmoke-asgi's live-stream shutdown. The state is now read by address after the pass, and a flush that began the drain says so and the drain starts at once, not at the next event or the 1 Hz tick.smoke-asgigates it with a request that raises SIGTERM in its first step, which puts the signal in the flush's pass every time;poe test-shimholds the shim's half. -
An ASGI application that installs asyncio's eager task factory is answered (SPEC L30). With
asyncio.eager_task_factorya task's first step runs insidecreate_task, and a response the application sent there, before its first await, was never answered: the executor parked its completion while still inside the job that spawned the task, and that job held the executor's state as a copy and stored it back as it returned, erasing the completion. Every request answered that way hung until the client gave up, on the default executor and underM0_INVERTEDalike, in any application whose lifespan startup callsset_task_factory(asyncio.eager_task_factory). The same cause as the entry above, found while fixing it;smoke-asginow runs the bare app with the eager factory installed. -
Under asyncio's eager task factory, an ASGI stream is stopped when its client leaves (SPEC L30). An application that installs
asyncio.eager_task_factoryruns each request's first step before the server has recorded which task serves the request, and a response that began streaming in that step was marked on the wrong task.- If an earlier request's task was still running on the same connection slot, the stream was marked on that task. That covers a keep-alive connection's previous request finishing its background work, and a connection that had just closed. The new client leaving then cancelled the other task, cutting the earlier request's background work short, and the stream ran on until the server shut down.
- If the body came from a child task, as Starlette's
StreamingResponsesends it, the stream was marked on the child. When the client left, the request was reported as failed after its response had begun.
A stream is now marked on its own request's task.
poe test-shimholds both shapes. -
Under
M0_INVERTED=1, a connection that arrives beside a streamed response is answered (SPEC L32). With the loop inversion and an eager task factory, a task's first step runs inside the event-loop pass that read its request, and a stream that sends many small pieces there fills the executor's chunk channel. The executor made room by running a pass of its own, inside the first one, and its wait overwrote the events the first pass had read and not yet reached. A new connection read in the same batch was not accepted until another one arrived. On Linux, a request on a keep-alive connection in that batch was never read, and its client waited for its own timeout. A full channel is now handed to the loop in order, as the pass would have handed it, and a pass never runs inside another.smoke-asgibuilds that batch on every run under the inversion. -
m0serve reads a command-line argument that is not UTF-8 instead of crashing on it. It cut
--name=value, the positionalMODULE:ATTR, and thePREFIX=of--staticand--mountwith a slice that asserts a UTF-8 character boundary, so an argument whose byte after the=or:continued a multi-byte character -- a directory name in a legacy encoding, say -- stopped the process on an assertion instead of serving it or printing a usage error. m0serve now reads its command line by bytes, with the same reader a Mojo host application uses, which already did; the two refuse the same malformed lines in the same words.test_cli.mojogates each of the four. Found in review. -
m0serve refuses more than 126 mounts before it binds, and
--doctorsays so too. Each mount is a lane of the loop's handler pool, which has room for 126. With a 127th, m0serve bound the port, imported every application and then exited 1 on a bareUnhandled exceptionline, while--doctorreported the same configuration as fine. Both now exit 78 at once, naming the limit. The limit applies whatever--blocking-threadsis set to, because a single ASGI mount among them puts the whole set on the pool.--doctornow reads the same ordered list of checks the server refuses by, so the two cannot disagree about which refusal comes first, and it lists every check it made, passing ones included.smoke-doctorgates both, and a handler pool also refuses a 127th mount's lane rather than writing past the end of its wake block. Found in review. -
An inbound WebSocket message reaches the mount that approved the socket when mounts are served inline (SPEC I12). Under
--realtimewith several WSGI mounts and no handler pool —--workers Nwithout--blocking-threads, or--blocking-threads 0— one handler serves every mount, and it delivered each inbound message, the synthetic/ws/messagePOST, to the FIRST mount's application at the first mount's prefix, whichever mount's view had approved the upgrade. The handler now records, per held socket, the application that approved it, and delivers the message there at that mount's prefix, as a handler pool already did per lane.smoke-django-realtime-wsgates it with two WSGI mounts served inline: each socket's message must reach its own mount's view with that mount'sSCRIPT_NAME, and a POST to either mount's/ws/messagefrom the network must be a 404. Found in review. -
--mount PREFIX=MODULEreports an application that raises on import, as the positional spec does, and never serves the next convention in its place. Discovery triesMODULE,MODULE.asgi,MODULE.wsgi, and more; a candidate that exists and raises on import is the answer, and the positional spec exits 1 with its traceback. A mount went through a copy of that resolver without the rule: withproj.asgiraising,--mount /=projreported the first candidate's one-line miss, or, ifproj.wsgiimported, silently served it — and--doctorcalled that healthy. Both now resolve through one function.smoke-servegates it with a package whoseasgi.pyraises beside awsgi.pythat imports: the positional spec, the mount and the doctor must each exit 1 with the traceback. Found in review. -
A WSGI body a handler thread would have streamed is closed when its response head cannot be built. A generator or other lazily produced body with no
Content-Lengthstreams from a--blocking-threadsthread, and a malformed header on it — a value or a name that is not astr— made the request a 500 without calling the body'sclose(), which PEP 3333 requires however a response ends. Django hangs itsrequest_finishedcleanup on that call. Both now close the body once before the 500.smoke-wsgi-streamgates both, countingclose()calls. Found in review. -
m0serve reports a listen failure in its own words, and waits out only an address in use. Every failure to bind was retried for five seconds and then reported as
address already in use: a--hostthat is not an address of this machine said the port was taken. Now only an address in use is retried — a restart racing the previous server's drain still succeeds — and anything else exits 1 at once, naming the address and the system's reason (cannot listen on 192.0.2.1:8080: ... Can't assign requested address). The Mojo host and every otherListenConfigcaller get the same rule.smoke-servegates it with an address that is not on the machine. Found in review. -
An
INSERT ... SELECTfromm0_arrayno longer inserts nothing (SPEC O4). Since 1.7.0 (andm0 0.3.0) the pointer-type tag bound with an array was a buffer freed as the bind returned. SQLite keeps that pointer and compares against it when the statement steps, so once the freed block was reused the scan found no array: the statement inserted 0 rows and raised nothing. The public helpers step straight after binding, and the scan's own copy of the tag usually took the freed block back and wrote the same bytes into it, which hid it from every test; any allocation in between that took the block emptied the scan. The tag is the literal's static storage again, at the bind and at the lookup.test_vtab.mojonow fills the heap between the bind and the step: 0 of 100 rows arrived in 30 runs of 30 on the 1.7.0 code. -
A NUL inside a
text()parameter is refused instead of matching the value cut short at it (SPEC O10). libpq reads a text-format parameter withstrlen, whatever length it is handed, soadmin, a NUL andxwas bound asadminand matched theadminrow.Params.textnow sends binary, framed by its length, and Postgres refuses the NUL with SQLSTATE 22021 (CHARACTER_NOT_IN_REPERTOIRE, now exported). What libpq takes only as a C string is refused before the call, under the same state: SQL text, a statement name, a namequote_identifierquotes, a connection string, andParams.literal, which stays text so the server can type it and so now raises. Where a statement was prepared withOID_UNKNOWNand the server typed that position as something other than text, give itliteral(), nottext(): a binary value is read in that type's binary form. -
Result.rawkeeps its result alive while its bytes are read (SPEC O16). The span it returned had an untracked origin, so a result whose last mention was therawcall was cleared on that line, and the span read freed memory: measured as the next query's value. The span now borrows the result, and the compiler keeps the result until the span's last use. No caller changes. -
A connection URL ending in
?or&connects (SPEC O7).openandopen_readonlyadd their defaults as query parameters, and they added a second separator to a URL that already ended in one:...?became...??connect_timeout=5, which libpq reads as a keyword named?connect_timeout, and...&became...&&, an empty keyword. libpq refused both before connecting, with a message about percent-encoding a password. -
The capability sheet said m0-postgres's binary results read like its text results for every type (SPEC O11). They do not for
timestamp,timestamptz,byteaandfloat4, nor for afloat8's text. In binary mode a timestamp'stext()is microseconds from 2000-01-01 rather than a date, abytea's is its raw bytes rather than the\xescape, and afloat4reads0.10000000149011612where text mode reads0.1. O11 now names the types that agree, and a Known issue records what each mode returns and what a fix needs.Result.text()'s own documentation made the same claim, and now names the same types. Read those types in text mode, which is the default. -
The
authscaffold's session cookie isSecureonce deployed (SPEC N45). Itsdeploy/fly.tomlforces HTTPS but never told the login so, and the login read the silence as off: a visit to thehttp://URL sent the session cookie in clear before Fly's redirect, and a stateless cookie copied there works until it expires. Every template'sfly.tomlnow statesAPP_SECURE = "1". The image states nothing, so a platform that says nothing is refused rather than served in clear.m0 newprintsAPP_SECURE=0in itsexportforhttp://localhost, where a browser need not keep aSecurecookie, and the template'ssmoke.shand tests set0.deploy/README.mdnames the two secrets a login needs on Fly and what a localdocker runpasses.smoke-scaffoldsigns in to the binary under the writtenfly.toml's[env]alone and requires aSecurecookie, andsabotage-scaffoldremoves the line and sets it to0. -
m0_core.json_parsereads a number by JSON's grammar.parse_json_intreturned a number's leading digits, so1.9and1e3read as 1, against its own contract ofNonefor a value that is not an integer. It andparse_json_numberalso read01as 1, andparse_json_numberread12abcas 12,1.5.3as 1.5 and took.5and1.. Both now returnNonefor a value JSON does not parse as a number: a zero before other digits, a.or an exponent with no digit after it, and anything after the number but whitespace,,,},]or the end of the body.parse_json_intstill refuses a fraction or an exponent, whichparse_json_numberreads. A body that relied on the lenient reading now getsNone. Found in review. -
The docs gate is one list, and CI's coverage checks no longer take a comment for a gate. The required
Docscheck andpoe check-docsnow run one script,scripts/docs_gate.sh(about 5 s): each used to skip checks the other ran, and neither ran the milestone rot gates, which a pull request touching onlydocs/could break unseen. The checks that every smoke and test task runs in CI, and that each SPEC row's gate declares its coverage, counted a task, a--coversdeclaration or a dev dependency named only in a comment intest.ymlorpyproject.toml; they now read both files as they run. Each CI job must also render and upload its own measurements: the postgres job's summary had been empty since the job was added, rendered with a flagemit.pydoes not have, while the check counted the other jobs' renders as its. -
Every CI job keeps the coverage its gates declare, and the release workflow's cleanliness check reads steps, not comments. The unit-tests and aarch64 wheel jobs ran their
emit.py --coversdeclarations with nowhere to record them, because the check that each job collects counted measurements only; both now record, render and upload like the rest, andemit.py --selftestno longer writes into, or fails under, the results file of the job running it. Eachwheel-consumejob must assert its own cleanliness in a step that runs, where the phrase in a comment used to pass.sabotage-specno longer reports a working rule MISSED when the first test file carries extra coverage, andtest.ymlno longer runs the milestone rot gates a second time beside the docs gate. -
Sixty-nine smokes no longer bind a fixed port or write into the checkout, and no task re-syncs the venv it runs in. They source
scripts/smoke/lib.sh, among them the WSGI and ASGI gateway's, the CLI's and the execution modes', the Mojo host's and the Mojo layer's, the Mojo mounts', and the wheels' and the scaffold's. Each server takes a free port, where fourteen of them shared 8080 and 8099 was four tasks', so they run beside each other and beside anything else on the machine, and none now fails, or is answered by another server, because something else holds its port (smoke-wheelrefused to run at all while anything listened on 8129).free_port Nfinds N ports in a row, for the probes that serve one shape per port. A smoke's logs go to a temporary directory that is kept, and uploaded by CI, only when the smoke fails, and.gitignoredrops the seventeen scratch files the smokes no longer write into the checkout. A server that dies while starting is reported at once with its log, where the smoke retried for a minute first. Cleanup stops the server's whole process group and waits for it, where it signalled one pid and could leave a supervisor's workers running; a probe that starts servers of its own runs in one process group with them, so a server it leaves behind is stopped with it; and an interrupted smoke no longer waits on a server that ignores TERM: the interrupt reaches thepsthe cleanup asks whether the server is alive, and the empty answer was read as "exited", so the cleanup waited on the server for as long as it lived. Ten tasks could run a nesteduv run poe(three on every run, seven to build something missing), which re-syncs the environment the task runs in, swapping packages under anything else using it and undoingnightly-tryorpy314t-try; they pass--no-sync, or take the build as a poe dependency. A new step on the Linux leg,poe check-task-shells, parses every task under dash and refuses such a call.
1.7.0 — 2026-09-27
MAX's parallel runtime does not survive a fork, so the Mojo host and
m0serve now refuse a forked worker in a binary that links it and name the
mode that serves it, and loops on threads are the documented way to more
than one core. m0serve's refusal reaches only a Mojo mount built against
MAX; the published wheel is built without it, so the served contract is
unchanged. m0-sqlite opens libsqlite3 at run time, and no binary in the
tree links it. Two defects the last pre-release run found are fixed: a
burst of new connections held the loop away from the connections it was
serving, and a pool thread starved for the GIL under a CPU-bound view. The
m0 wheel ships as m0 0.3.0, with a login in the layer, an auth
scaffold template written on it, the storage packages, and a doctor that
names what an upgrade changed.
Added
-
m0 0.3.0: this release's framework, for applications built withm0. What changed sincem0 0.2.0for someone writing an application:- New:
m0 new NAME --template auth, aviewslist behind a login, andm0_http.login, the module it is written on (N43–N45, below). A swap sends a request header withheader=RequestHeader(...), written ashx-headersbyHtmxand refused byDatastar. That is how a DELETE carries its CSRF token under htmx 4, andcsrf_headerbuilds that header. m0_sqliteandm0_postgresship in the wheel and link nothing (N39). Thelivetemplate keeps its kick count in SQLite, and its image installslibsqlite3-0(N40).max-coreis a pinned companion, andm0 doctor's newmax-gatedcheck refuses any other version (N41).- The upgrade path: nothing rewrites a project's files, so
m0 doctornow names each scaffold file that differs from what the runningm0writes, reported and never failed (N42). Between 0.2.0 and 0.3.0 six of the ten files every template writes changed, the Dockerfile among them, whose 0.2.0 form installs nolibsqlite3. Withmax-coreinstalled,mojo-gated's fix is oneuv addthat moves both pins. M0_WORKERSabove 1 in a binary that links MAX's parallel runtime exits 78;M0_THREADSserves it (E32, D48). On macOS a build beside an installedmax-corelinks the runtime whether or not the source imports it (ROADMAP Known issues).- A route that takes GET answers HEAD, and
Allownames HEAD beside GET (N38).url_forrefuses a.or..parameter (N5).issue_sessionrefuses an expiryverify_sessioncannot read, such as one in milliseconds.Login.from_envrefuses a key under 32 bytes and aPREFIX_SECUREother than1or0. - The command line:
m0 newrefuses a name that ends in-;m0 doctorbounds its run of the binary at 30 s and skips an editor's hidden lock files; two builds of one project wait for each other rather than race; and a second Ctrl-C inm0 devends the draining server by name and exits 0 (under Fixed).
- New:
-
m0serve refuses a forked worker when MAX's parallel runtime is linked (SPEC E33, DECISIONS D51). A Mojo mount that imports
max.algorithm.parallelizeputslibAsyncRTMojoBindingsin the image;--workers Nabove 1 and--reload(which supervises even one worker, forked) then exit 2 before the bind, naming--spawn-workers, whose worker execs the binary and starts the runtime fresh, and--doctorreports the check (workers-vs-parallel-runtime) andtopology.parallel_runtime. The fact moved tom0_http.parallel_runtime, the one function the Mojo host and m0serve both read, so the shippedbin/m0serve, which links no MAX, passes the check at two workers. Measured with the refusal removed: a forked worker answered/par/serin 23 ms and/par/parnever, and the pool thread it took was abandoned at the drain's 5 s bound.smoke-serve-parallel-runtimegates it on every pull request under the samemaxgroup, with two exec'd workers each answering aparallelize(apps/serve_parallel, over the jobapps/host_parallelnow keeps incompute.mojo). CI's macOS leg found the toolchain's part: a build made beside an installedmax-corelinkslibAsyncRTMojoBindingswhether or not the source imports MAX (the demo-mountbin/m0servebundled three runtime files without MAX and four with it; Linux, three either way), so on such a machine every binary reads as linked and E32's and E33's refusals fire for a MAX-free one — a Known issue now,M0_THREADSand--spawn-workersserving it; the gate's control reads the binary's own load commands and holds the doctor to them rather than assuming. -
The Mojo host refuses forked workers when MAX's parallel runtime is linked (SPEC E32, DECISIONS D48).
M0_WORKERSabove 1 in a binary that carrieslibAsyncRTMojoBindings— whatmax.algorithm.parallelizeneeds — exits 78 before the bind, naming--threads (M0_THREADS), and--doctorreports the check (workers-vs-parallel-runtime). Measured before: aparallelizein a forked worker never returned, and the request that hung took its worker's loop and the shutdown with it, becausefork()copies the calling thread alone and the runtime's workers are started beforemain.smoke-parallel-runtimegates it on every pull request, both platforms, againstmax-coresynced by the newmaxdependency group in that step alone;sabotage-host --only parallelremoves the check and must be caught on the served prefork. The host's unit tests supply the fact wherever a verdict is about something else, sotest_host.mojoholds with themaxgroup synced, wheremojo runmaps the runtime into the compiler's process. -
m0-sqliteopens libsqlite3 at run time (SPEC O17, O18; D49). The package reached SQLite throughexternal_call, which put the library on the link line of every binary that used it:-Xlinker -lsqlite3andlibsqlite3-devon Linux, andmojo runof any test that touched a database failing there withSymbols not found.Connectionnow opens the library throughSqliteLib(src/lib.mojo,m0-postgres's shape and its three rules: handle and pointers in one struct, every entry point behind a method, the image pinnedRTLD_NODELETE), fromM0_LIBSQLITE3or a search path, refusing one below 3.20.0, built without threads, or missing a symbol, each an error naming what it found.Statementholds a copy of the entry points it calls and outlives every handle of the library; the virtual-table callbacks reach it through the module buffer SQLite already owns. Nothing in the tree links libsqlite3 any more —build-appsfails ifdatastar_todo's binary names it — and six of the package's seven test files run undermojo run.test_lib.mojois the gate; docs/notes/sqlite-at-run-time.md records the round. -
The storage packages ride in the
m0wheel, thelivescaffold keeps its kick count in SQLite, and MAX is a pinned companion (SPEC N39–N41; D50; docs/notes/storage-and-max-in-the-wheel.md).m0_sqliteandm0_postgresare two more source trees underm0/_mojo/, usable with no link flag because both open their library at run time; the wheel smoke opens SQLite through the installed tree underm0 test.live's handler opens astore.mojoinmake(once per worker, loop or pool thread, after the fork) and the kick view counts in it inside its own request, sosmoke.shrestarts the server and finds the kick./statsreads the database and the board's word drives only the wave, sosmoke-scaffoldkeeps its stream open across the kick and requires a later frame whose lowest bar stands above the highest the wave drew before it;sabotage-scaffold's "a kick never reaches the wave" is caught there. The scaffold's image installslibsqlite3-0(RUNTIME_LIBS,libpq5by one build argument), records it inabout.json, and pointsM0_DBunder/app/datafor a volume.gated_maxis read from the root'smaxgroup besidegated_mojo, andmax-gatedjoins the ONE list of checks: absent passes naming theuv add, another version is refused with its sentence, the gated one passes; the scaffold'spyproject.tomlcarries that line substituted and itsAGENTS.mdthe rules.smoke-parallel-runtimenow also runs the release recipe on the MAX-linked probe and serves from the bundle,libAsyncRTMojoBindingsbeside it. -
An
m0upgrade says what the scaffold it came from lacks, and moves the toolchain in one command (SPEC N42, D52; docs/notes/the-scaffold-upgrade-path.md). Nothing rewrites a project's files, so a newerm0left everythingm0 newwrote as the older one wrote it: between 0.2.0 and 0.3.0 six of the ten files every template writes changed, the Dockerfile among them, whose 0.2.0 form installs nolibsqlite3form0_sqliteto open.m0 doctornow compares the scaffold's own files (all but the README andpyproject.toml) with what the runningm0writes for the project's name, and names those that differ: reported, never failed, since a difference is as often the application's own edit. Withmax-coreinstalled,mojo-gated's fix is oneuv addof both pins:max-coreandmojoeach pinmojo-compilerexactly, so movingmojoalone could not resolve. The scaffold'sAGENTS.mdand the wheel's README say how to upgrade.smoke-scaffoldholds it per template (a fresh scaffold names nothing; an edited Dockerfile is named), andsabotage-scaffoldremoves the naming. -
A login in the layer, and a scaffold template written on it (SPEC N43–N45, D53; D38 and D44 retired; docs/notes/a-login-in-the-layer.md).
m0_http.loginis the glueapps/fragment_noteswrote by hand and the soak application copied with its names changed:Login.from_env(PREFIX, cookie)refuses an incomplete configuration by name.PREFIX_SECUREis1or0, and any other value is refused rather than read as off, soM0_NOTES_SECURE=truenow stopsfragment_notesat startup where it used to dropSecuresilently.sign_in(user, password)is the credential check and the session in one call.session_ofandsign_outread and end a session.refuse_signed_outanswers a navigation with a 303 and a swap with a 401 carrying the form.csrf_refusalreads the header before the field, never the query string, and refuses everything when there is no session.csrf_input,csrf_headerandno_store.
A swap now sends a request header with
header=RequestHeader(...):hx-headersforHtmx, refused byDatastarand by the trait's default. That is how a DELETE carries its CSRF token under htmx 4, which sends a DELETE's fields in the query string.m0 new NAME --template authwrites theviewslist behind the login:- Every write carries the token.
- The login and the logout are plain forms.
mainrefuses a missingAPP_KEYorAPP_PASSWORDwith exit 78 beforeserve, so the doctor refuses it too.m0 newprints theexportthat sets both variables.
smoke-scaffoldholds the template on the wire, andsabotage-scaffoldreverts each of its rules.test_login.mojoandtest_html.mojohold the module and the header, each rule shown failing with its behaviour reverted. The scaffold'sAGENTS.mdnow names the module where it named the notes app's source, som0 doctorreports that file as changed in a project written by an earlierm0.apps/fragment_notesruns on the module, andsmoke-fragment-notespasses unchanged. Its key must now be at least 32 bytes, andserve-fragment-notes' development default is lengthened to match.sabotage-notes-login's CSRF arms now revertlogin.mojoitself, and a new arm removes the layer'srequest_headercall.
Changed
-
Loops on threads are the documented way to more than one core for an m0 application (D48, superseding D35's "prefork first"):
M0_THREADSacross cores, a handler pool for the views that compute, one loop on one vCPU, and prefork kept for an application that links no MAX and wants a supervisor. The host page, the deploy page and the scaffold'sAGENTS.mdsay so. Nothing changes for an application that sets neither. -
The handler pool's fairness probe runs on every pull request on Linux, and judges job order rather than latency (SPEC E11, E34; docs/notes/fairness-judged-by-order.md).
probe-pool-fairnesswas pre-release; it now runs in test.yml'spool-fairnessjob, with the reference Mac keeping the macOS run. Its latency bounds were too close to the machine for that: on a 4-vCPU KVM guest a fair run's max reached 247 ms against 250, and the old shapes broke the bounds by as little as 4 %. The verdict is now the number of requests passed over by more than 100 later ones, at most 5 a run and none by more than 1000. A pause of the whole process passes nobody over, so two 300 ms stops of the server, which failed the latency verdict five times in five, pass it. The keep rule's arm (E34) runs too, at a load chosen for it: five threads, twenty connections, a 0.65 ms view. With the rule off, who starves depends on how many jobs a 1 ms slice holds against how many threads wait. The first load's 0.3 ms view sat on the boundary between three jobs and four, and starved every AMD EPYC 7763 runner and no Intel Xeon or AMD EPYC 9V45 one. The new view is two jobs a slice on every machine measured. On twenty runners of five CPU types, fair runs show no long waits, the barrier off 7–116 a run and the keep rule off 29–93. The reference Mac's first run, on an M4, agrees: none, 15 and 34.M0_FAIRNESS_EXPECT_STARVATION=0skips the keep arm. Each arm's figures are recorded with the job's measurements, with a request's share of the GIL against the 1 ms edge the keep arm needs. -
release-m0.ymlrefuses a releaseTestshas not passed, and a wheel that does not record onemax-corepin (SPEC N32). The build job asks the runs API for the latestTestsrun on the tagged commit, or, for a merge commit, on the pull request head it merges: a merge theautomergelabel makes is pushed with the workflow token, which starts no workflow, so such a commit has no run of its own (m0-v0.1.0's did not). A run still going, failed or cancelled, or none at all (a change to docs alone gets none) refuses the tag by name. The wheel's_build_info.jsonmust hold onegated_maxpin, as it holds onegated_mojo, and the scaffold the job writes must name that MAX. Checked against both published tags, which pass, and a docs-only merge, a cancelled run and a running one, which do not.m0_release_problemsholds both rules; its selftest reverts thirteen.
Fixed
-
A burst of new connections no longer holds the event loop away from the connections it already serves (SPEC C8). A pass admits at most 16 new connections (
ACCEPT_BATCH), off the listener or off a sibling's accept-share channel, AFTER it has served the events of the connections it holds; what a batch leaves is taken by the next pass, whose wait does not block. The loop used to drain the listener to EAGAIN — on Linux, where epoll reports no backlog depth, through connections that arrived during the drain, up tomax_connections— and admitting a connection runs its eager read, which on a loop that servesfuncitself is the whole request: a keep-alive/fastwaited 625 ms behind 120 queued 5 ms requests, and waits 79 ms now, the rest of one batch.M0_ACCEPT_BATCHoverrides the batch, 0 taking the whole backlog as before (an A/B knob).smoke-accept-batchgates it on both legs, its knob-off arm on Linux required to show the old starvation. Found by the 2026-09-26 pre-release run. -
A
--blocking-threadsthread no longer starves waiting for the GIL under a CPU-bound view (SPEC E34). Inside its hand-off slice a pool thread took the GIL back after every job, dropping it only to pop the next; each drop woke a parked thread that found the GIL taken again and waited once more behind the others, so the slice's hand-off went back to the thread that had just held it. Two threads alternated while two starved: on 4-vCPU Linux the fairness probe's max was 335–1637 ms in 14 runs of 14, and withwrkthe p99 itself 394–515 ms. A thread inside its slice now takes a job already queued without dropping the GIL (OffloadPool.try_next_job, which never waits): the max is 12.9–20.6 ms, thewrkp99 9.3–12.7 ms at the same throughput, and the trivial route serves up to 28 % more at 256 connections.M0_POOL_TURN_KEEP=0restores the old shape (an A/B knob), andprobe-pool-fairnessruns it on Linux as a third arm that must starve a waiter. Found by the 2026-09-26 pre-release run, which recorded it as a VM's outlier; a recorder beside the probe showed the machine never stalled. -
A WebSocket lingering for the peer's Close reply is no longer pinged. After the application's Close the loop waits for the peer's (RFC 6455 §5.5.1) — and the stream heartbeat kept treating the slot as a live socket, so a ping went out during that wait. A peer that had read our Close, answered it and was waiting for the FIN read
0x89 0x02 "hb"instead. Nothing follows a Close (§1.4), so the heartbeat now skips a slot whoseclosingis set; the linger's own bound still finds a peer that never replies. Found bystress-asgiin 3 rounds of 30 under CPU hogs, which widen the window between the Close going out and the reply being read — CI'ssmoke-asgiruns the same probe without hogs and never saw it.ws_probe.pynow gates it deterministically (SPEC L29): its quiet-linger phase holds the Close reply for three heartbeat periods and requires silence, then a FIN; and its close-order phase names a frame it reads after the handshake by opcode, where it used to report the RST that had not happened. -
A route that takes GET answers HEAD (SPEC N38). The view table matched methods exactly, so every
Viewsroute answered HEAD 405 withAllow: GET, OPTIONS,/healthincluded, where RFC 9110 has a server that supports GET support HEAD; an uptime check that sends one reads the application as down.unotes, the application-layer soak, found it on its own deploy.Router.matchnow answers a HEAD that no route registers for its path with the route a GET would reach, in both tables and on the loop, and the server needed nothing: it already dropped a HEAD's body and kept the GET'sContent-Length. A route registered for HEAD itself still wins over its table's GET, and a path with no GET is still a 405 for HEAD.Allownames HEAD beside GET, so a 405's and a preflight's header changes:GET, POST, OPTIONSis nowGET, HEAD, POST, OPTIONS.test_views.mojoandtest_router.mojohold the rule, andsmoke-blobssends HEAD to a read view, a loop route and the stream on the wire (head_probe.py --twin, new beside--hold). -
url_forrefuses a dot segment (SPEC N5). A parameter of.or..reversed to/notes/.or/notes/../delete, which a browser resolves to another route before it sends the request: the empty value's failure, whichurl_foralready refused, one step removed, and one no encoding avoids,.being unreserved and%2eread as a dot by the URL standard.url_forandMount.url_forraise for it as they do for an empty value. Every application inapps/reverses numeric ids. -
issue_sessionrefuses an expiryverify_sessioncannot read. The verifier reads at most twelve digits of expiry (SESSION_EXP_DIGITS), and the issuer signed any non-negative one, so an expiry given in milliseconds issued a cookie that read back asmalformedon every request: a login that could never succeed, reported as a forgery. It raises now, as its docstring said it did. -
Five
m0command-line defects, reaching users withm0 0.3.0:m0 new foo-was accepted, anduv syncthen failed on the project name. A name ends with a letter or digit (SPEC N27); exit 2 as before.m0 doctorranbin/server --doctorwith no bound, so a binary whosemainnever reachesserveserved instead of answering and the doctor waited for ever, and one that could not be run ended in a traceback. The run is bounded at 30 s, in a session of its own so everything it started is ended, and either failure is one line and exit 1.m0 doctor's staleness check stat-ed everysrc/**/*.mojo, and an editor's lock file (.#views.mojo, a symlink to nothing) raised. Hidden files are skipped, asm0 dev's poll skips them.- Two builds of one project at once (
m0 devrebuilding while./smoke.shrunsm0 build) staged at the same path and could rename each other's half-written binary into place. A build holdsbin/.build.lockfor its length, and a second waits, saying so. - A second Ctrl-C while
m0 dev's server drained escaped as a traceback, and the server was SIGKILLed unannounced. It is ended at once and named, andm0 devexits 0.
test_m0.pyholds each, and each was reverted to show its test fails.
1.6.0 — 2026-09-24
The request an application sees is now the one its client sent, and the
ASGI executor behaves as Starlette, FastAPI and FastHTML expect: FastHTML's
and FastAPI's own examples, run beside uvicorn, found the ASGI and request
fixes below. Descriptors no longer leak into a process the application
starts, m0-datastar passes the Datastar SDK's own conformance cases, and
the m0 wheel ships as m0 0.2.0, carrying Query and push=True.
Added
-
m0 0.2.0: this release's framework, for applications built withm0. What changed sincem0 0.1.0for someone writing an application:- New:
Query(N36) andpush=True(N37), both below. m0-datastar's frame builders nowraiseon a line break in a one-line field, so a call site must be in araisesfunction or catch it.patch_signalssplits multi-line JSON, remove mode writes noelementsline,execute_scripttakesattributes,redirectescapes its location, andread_signalsreads a DELETE's query.DatastarStream.caught_upandsend_toanswer a reconnect the journal cannot catch up (I27–I30).- A request reaches a view as its client sent it: no invented
Content-Length,ConnectionorHost(L25), and a raw@in its path or query no longer sends it to/(A22). A native 1xx, 204 or 304 carries no invented entity headers (A21). - The scaffold's
AGENTS.mdcovers a plain-form login, a cookie in a test, and whereread_signalslooks.
- New:
-
The Datastar SDK's own conformance cases gate
m0-datastar(SPEC I27). The SDK's specification (sdk/ADR.md), its 20 cases and thecompare-sse.shthat judges them are vendored from the pinned tag intopackages/m0-datastar/test/sdk/, each file hashed.poe check-datastar-sdk, insidetest-all, runs every case and refuses fixtures from any tag but the pinned one;poe sabotage-datastar-sdkreverts each rule and insists it is caught. The package passed 14 of the 19 get-cases before; the misses are under Fixed.execute_scripttakes the SDK'sattributesoption (name="value"strings, written verbatim), andDatastarStream.execute_scriptpasses it andauto_removethrough.packages/m0-datastar/AGENTS.mdrecords where Datastar facts come from and how to move the pin. -
push=True: a swap that moves the address bar (SPEC N37, D46).Fragment.swap,Fragment.elandHtml.swap[V]take it, andFragment[Htmx]writeshx-push-url="true"beside the swap, so a view reached by a swap — a filtered list, a detail — can be reloaded, linked to and gone back to.unotestyped that attribute by hand on every link, against the scaffold's own rule. Only agetis pushed (the layer refuses the rest);Vocabulary.push_urlis new with a default that REFUSES, so an application's own conformance still compiles, andFragment[Datastar]raises — Datastar's free bundle has no history handling.apps/fragment_notespushes its two links and its smoke holds them. -
Query, a query-string builder besideurl_for(SPEC N36).url_forfills and encodes a path and nothing past it, and the encoder it uses was private, so an application with a GET filter form wrote a percent-encoder of its own —unotes, the layer's soak application, did.Query().add(name, value)thenq.on(path): pairs in order, names and values encoded byte by byte (%20, never+), an empty value skipped so a filter's URL is only what is set,add_emptyfor the pair whose presence is the meaning. Exported fromm0_http. -
The
m0wheel and its CLI; published asm0 0.1.0on 2026-09-21 (SPEC N23–N26, D39–D43).packaging/m0/builds a pure-Python wheel that carries the framework's SOURCE — the five trees an application compiles against, mapped file by file fromgit ls-filesintom0/_mojo/<import name>/— and a stdlib CLI that builds against it from outside this repository:m0 build(renamed ontobin/server, never written over a running binary;--releasecompiles for the platform's baseline CPU and bundles the Mojo runtime intodist/),m0 test(mojo runper test file, no C compiler needed),m0 doctor [--json](the toolchain checks, then the binary's own--doctorwith whatever follows--), andm0 include. The wheel is gated on ONE exact mojo, read from the root pin, and m0 refuses any other with the pin to add; it runs themojoin its own environment, neverPATH's. Exit codes are a closed set (0, 1, 2, 78). Versioned apart from the repository,0.1.0and0.xuntil the application-layer soak.m0 new, the templates,devandimagecame in the pull requests below.smoke-m0-wheelruns on every pull request, both legs. -
m0 newand the two templates it writes (SPEC N27–N29, D44; docs/notes/the-scaffold.md).m0 new NAME [--template views|live]needs no toolchain and no network — it runs throughuvxbefore anything is installed — and writes an application a person can read in one sitting:src/andtest/, apyproject.tomlpinning BOTHmojoandm0exactly,AGENTS.md(the rules that are not obvious from the code, written for a coding agent), an executablesmoke.sh, a workflow, anddeploy/.viewsis a server-rendered list swapped in place by htmx 4;liveis a producer pushing full-state Datastar frames to every tab. Both are sessionless. The templates are real source files that compile unsubstituted —poe check-templates, insidetest-all— so substitution is plain string replacement and there is no template engine.smoke-scaffoldruns on every pull request, both legs. -
m0 dev,m0 image, and the workflow that publishesm0(SPEC N30–N32; docs/notes/dev-image-and-a-release.md).m0 dev [-- HOST_ARGS]is build-then-swap: the old server keeps answering while a build runs and after one fails, and only a build that succeeded ends it — SIGTERM by pid, a six-second wait for the pid to exit, then the new binary, so there is never a second process on the port. The watcher is a stdlib mtime poll ofsrc/andpyproject.toml.m0 image [--tag T] [--target-cpu CPU] [-- DOCKER_ARGS]builds the scaffold'sdeploy/Dockerfileand prints the image's ownabout.json; it needs docker and no toolchain, and does not deploy. The scaffold's Dockerfile takesTARGET_CPUas a build argument and itsabout.jsonrecordscpu, what the builder's release build said it compiled for.smoke-scaffold-dev(both legs) andsmoke-scaffold-image(Linux, the first x86-64 build of the scaffold's image, its Dockerfile built as written) run on every pull request, which retires thedeploy/gap the scaffold shipped with.release-m0.ymlpublishes fromm0-v*tags through a trusted publisher and an environment of its own; it is held to its rules bycheck-docs. Its first run was them0-v0.1.0tag on 2026-09-21: green in both jobs at the first attempt, and the published wheel then passed docs/RELEASING.md's two after-upload runs (a scaffold synced from the index, and the quickstart page verbatim). -
The scaffold's
AGENTS.mdsays three things the first scaffolded app had to find out (unotes, the application-layer soak's subject; itsSOAK_LOG.mdfindings 4–6). Login and logout are plain forms answered with a 303, never swaps — a swap leaves the address bar behind, so signing in left the application under/login. A hand-builtHTTPRequestparses noCookieheader, so a test of a view behind a session fills aRequestCookieJaritself; without the two lines every such test is answered as signed out, and finding them meant reading framework source. And the worked login,apps/fragment_notes, is not in the wheel: the page now gives its URL and says to read its renderer as well as its views. Reaches a scaffold with the nextm0release. -
Documentation for the Mojo stack, and a quickstart CI executes (SPEC N33–N35, D45). Six pages under
/mojo/on the site, their URLs permanent: the section index (where "preview" is said, once), a quickstart, the host, views and fragments, deploy, and the way from an m0serve mount to a binary of its own.packaging/m0/QUICKSTART.mdis run on every pull request, both platforms, bysmoke-quickstart-mojo:uvx m0 newto a served page,m0 test,m0 doctor, andm0 devswapping in an edit, against the tree's wheel, in a scratch directory on a strippedPATH. It lives underpackaging/so that a pull request editing only the page still runs it.check-docsholds the pages' tables tohost_checks, the host's help text,m0's parser and its checks list, holds the six URLs, and puts all six pages under the bare-figure rule.llms.txtgains the stack's operating contract; the pages ride in the existingllms-full.txt. The site deploys on a release, so merging this publishes nothing. A scaffold's first build prints no compiler warning (N34):smoke-scaffoldrefuses one that does. -
A command line and a doctor for Mojo host applications (SPEC E30–E31).
serve[H, P](AppConfig())now reads the binary's flags —--host,--port,--workers,--threads,--blocking-threads,--access-log,--sse-heartbeat-ms,--app-tick-ms,--max-keepalive-requests,--qos— with m0serve's precedence, flag overM0_variable over default, and m0serve's strictness: an unknown flag, an unreadable value or a positional is the usage and exit 2.--doctorprints the configuration the binary would serve as one JSON object (the last line of stdout, in m0serve's report shape, each failed check carrying itsfix) and exits with the code serving would exit with, having bound nothing;smoke-host-doctorruns twenty configurations both ways and requires the codes to agree. An application that prints its own address takeshost_config()so the banner names the port a flag moved. Nothing changes for an application that is passed no arguments.
Changed
-
m0-datastar's frame builders raise on a line break in a one-line field (SPEC I29).patch_elements,patch_signals,execute_script,redirectand theDatastarStreambroadcasts refuse a selector, mode, namespace, view-transition selector or event id carrying CR or LF: two breaks ended the event, and a selector built from request data could open an event of its own. Each is nowraises, so a caller that cannot raise catches --apps/datastar_counter'stickdoes. -
A child process reaches the bus only when it is handed it (SPEC G16). The bus is close-on-exec now, like every descriptor the server creates. A child started with
os.system, withpty.forkand exec, or withsubprocessandclose_fds=Falseused to inherit it by accident, andm0pub.publish()there reached the real bus. Now it publishes nothing, andpublishreturns 0. Hand the child the bus instead, the way m0pub has always documented:subprocess.Popen([...], pass_fds=m0pub.child_fds()). That carries the event-id page as well, so the child's frames are numbered. -
A native 1xx, 204 or 304 no longer carries
Content-Type: application/octet-streamorContent-Length: 0(SPEC A21).HTTPResponse's constructors add neither default for a status that has no content, and the event loop drops a length and a body that a handler set on a 1xx or 204 itself.reply.empty(304),reply.no_content(), the Views table's OPTIONS answer and the static mount's revalidation all change on the wire. A response with a body keeps both defaults.HTTPResponse(..., invent_entity_headers=False)adds neither for any status: it is how the gateway relays an application's head. -
A parsed request carries only the headers its client sent (SPEC L25).
HTTPRequest.from_parsedno longer fills in aContent-Length, aConnectionor aHost, so a native handler readingreq.headers, a WSGI environ and an ASGI scope all see a GET without thecontent-length: 0andconnection: keep-alivethey used to carry, and a WSGI application gets noCONTENT_LENGTHfor a request that sent none, which PEP 3333 allows.HTTPRequest(..., invent_headers=False)is new; the default still fills all three for a client.connection_close()now answers HTTP/1.0's default from the protocol, which moves two edges: an HTTP/1.0 request whoseConnectionis a list (Keep-Alive, foo) now closes, the value being compared whole asclosealways was; and HTTP/1.2 to 1.9, which the parser accepts, now persists as 1.1 does (RFC 9110 §2.5) where an inventedcloseused to end it. AndTransfer-Encoding: chunked, chunkedis refused with a 400 beside every other request whosechunkedis not its final coding: RFC 9112 §6.1 forbids applying it twice, and the loop decodes one layer. -
Datastar is pinned at v1.0.4 (was v1.0.3; DECISIONS D20).
m0-datastar'sVERSION, the three demo pages' CDN pins, thelivescaffold template's, and the SDK conformance-case URL. Not a protocol change: the v1.0.3...v1.0.4 compare is 74 files with none undersdk/, and every string this tree's behaviour rests on is in both bundles in the same count —Datastar-Request, the three accepted content types, the key parsersplit(/:(.+)/),datastar-patch-elementsand-signals,retry,contentType,FetchFormNotFound. The two source files that touch this contract were read:patchElements.tsremoves committed merge-conflict markers and adds two casts, andfetch.tsregisters@query()(as theQUERYmethod, not yet inDatastar.verbs()) and stops arequestCancellation: 'cleanup'fetch dispatching events for an element that has since been removed. Rocket, the newdatastar-rocket.jsweb-component bundle, is not adopted: it is client-side only, in beta, and orthogonal toVocabulary. -
D46's retiring condition now names a price, not a release. Datastar's free bundle still names neither
pushState,replaceStatenorpopstateat 1.0.4, and the two attributes that would spell a push,data-replace-urlanddata-query-string, are Pro. SoFragment[Datastar]refusingpush=Trueis not waiting on a version. -
apps/blobskeeps sending a picture, and now says why (DECISIONS D47; docs/notes/the-picture-on-the-wire.md). An investigation into Datastar 1.0.4's Rocket component bundle asked whether the demo could send blob state and let the client draw it. Measured over 40 consecutive frames instead of argued: 2,544 B of signals per frame at the median — not the 11 KB the 16-blob worst case implies — 5 of 16 slots filled, 4 of those 5 changing every frame, 92.7 % of the bytes vertex text. So delta frames would save a fifth, and the only real cut is to stop sending vertices, which moves the kernel with it:matchis what makes CSS interpolation coherent, so a client drawing state must reproduce the whole contouring pipeline. No code changed; the note and the row record the measurement and what would retire the decision. -
The built-in htmx vocabulary is htmx 4 (SPEC N22, DECISIONS D6 retired; docs/notes/the-layer-moves-to-htmx-4.md).
Fragment[Htmx],page_or_fragmentandapps/fragment_noteswere gated against 2.0.4 and are now gated against 4.0.0:page_or_fragmenttakesHX-Request-Typeat its word —partialis the fragment,fullthe document — and reads a request without it by the htmx 2 rule, unchanged, so an application still on htmx 2 is answered as before. Every answer'sVarygainsHX-Request-Type, fifth and last; that is the one change on the wire for an application that did nothing.Htmxtakes htmx 4's sixth verb,query. A swap is spelled in the same three attributes, byte for byte.- The notes app sends a DELETE's CSRF token as an
X-CSRF-Tokenheader (hx-headerson the form), because htmx 4 puts a DELETE's fields in the query string and has no setting to change it; its server reads the token from that header or the body and never from the URL. - For applications moving: htmx 4 swaps every 4xx, so answer an error a person may see as a fragment.
-
A Mojo host's refusals (exit 78) now end with the fix in parentheses, naming both spellings:
M0_THREADS must be at least 1, not 0 (set --threads (M0_THREADS) to 1 or more, ...). The opening words are unchanged. -
Report, the pure half ofm0serve --doctor, lives inm0_http.doctorso both doctors render one shape;m0_wsgi.doctorre-exports it and m0serve's output is byte-identical. -
blobs.m0serve.dev no longer slows to 2 Hz when idle. The app drops to its idle rate a minute after the last click, and on 1.5.0's live stream that was nearly every visit, where each shape moved in 500 ms straight lines. The deploy sets
M0_BLOBS_IDLE_HZ=10. The app's own default is unchanged.
Fixed
-
A raw
@in a request's query or path sent it to/, and a client URL to another host (SPEC A22).URI.parseread the first@anywhere after the scheme as the end of a userinfo and discarded everything before it, soGET /contacts?q=a@breached the application asPATH_INFO='/'with an emptyQUERY_STRINGon every server shape. A POST to/save?x=a@bran the root's POST handler, and so did/users/@alice?page=2. The outbound client dials the parsed host, sohttp://api.test/lookup?email=x@evil.testconnected toevil.test. Only an@inside the authority (before the first/,?or#, RFC 3986 §3.2) now ends a userinfo;userinfo_separatoris the rule, besidescheme_separator, which fixed the same search for://. Browsers and htmx encode@, so it took a typed or hand-built URL; a Flask app compared against Werkzeug found it.test_uri_userinfo.mojoholds it, andsmoke-wsgisends a GET and a POST with the raw byte. -
A Datastar replay too big for the connection is no longer served in part (SPEC I30).
DatastarStream.openreplayed a reconnect's missed frames oldest first into a 64 KB outbox. When they did not fit, the newest were the ones refused, and the client sat on a state from several changes ago with nothing in the log: thirty 3 KB frames arrived as 1-21. A reconnect behind an evicted frame got the frames after it with a hole in the middle. Both are now gaps. Nothing is replayed,caught_up(slot)answers False (as it does for an id older than the process's own history, or ahead of it), and the newsend_to(slot, frame)queues the view's resync for that connection alone.apps/datastar_todosends its current list that way, andsmoke-todoreconnects thirteen 2 KB adds behind to hold it.refused()counts the live frames a full outbox refused. -
Security:
m0-datastar'sredirectpasted its location between single quotes (SPEC I29), so a'ended the JavaScript string and the rest ran as script, and</script>ended the element whatever the quoting. The location is now a string literal with",\,<, every control byte and U+2028/U+2029 escaped. -
m0-datastaragainst the SDK's own cases (SPEC I27, I28):read_signalsread a DELETE's signals from its body, where Datastar sends none -- the bundle and the SDK'sReadSignalstable put them in?datastar=, as for GET -- and so returned{};patch_signalswrote multi-line JSON as one dataline, so the client received{and read the other lines as fields of their own; andremovemode carried an emptyelementsline. -
Security: an ASGI application's message for a client that had left could reach a different client (SPEC L20). The executor decided whether a connection was gone by asking about the task making a send, not the connection the send addressed, and the loop reuses a slot the moment a connection closes. So a
sendan application kept for a client that had gone, called from any live task — a broadcast, another request, a background task — was written into whichever client now held that slot. FastAPI's documented multi-client chat room did it with no change: the next client to connect received every message addressed to the one that left, including its personal ones. A raw ASGI push hub's stale streamsendwrote into another client's response the same way. The same rule also refused a disconnect hook's sends to the sockets still connected ("someone left" reached none of them). A send is now judged by the task that owns the connection it addresses: a send to a socket that has gone raisesClientDisconnected(anOSError, uvicorn's name for it), and one to a stream that has gone is a no-op, as ASGI 2.3 specifies. Two paths never told the executor a socket had gone at all, and are closed too: a socket its application closed (each socket now keeps its own record of its accept and its close, where the slot's record was the next client's — a latefinallyclosed that client, a second close rejected its handshake), and a socket the server ended itself after a message over its outbox cap (the loop now tags every connection an executor produced, routed to that executor). And a task an application left behind, sending after the application returned, could answer the slot's next request with its response; a send once the response is over now answers nothing. Found by serving FastHTML's and FastAPI's own examples beside uvicorn. -
A process the application started inherited the server's connections and channels (SPEC G16). Nothing the server created was close-on-exec, so a child that execs —
os.system,pty.fork,subprocesswithclose_fds=False— held every client connection open at that moment, the listener, the channels between the loop and its pools, the bus and the shutdown pipe: 8 to 46 descriptors, depending on the mode. A connection the server closed stayed open in the child. FastHTML's terminal example took 10 s to close a WebSocket, and a child that runs another user's commands, like that terminal's shell, held every other client's connection. Every descriptor is now close-on-exec, atomically on Linux.--spawn-workerskeeps exactly what its new image adopts across its own exec, where the supervisor used to clear the flag for every exec in every mode. Andm0pubwrites only to its own bus, which it recognises by device and inode (M0_BUS_WRITE_IDS, exported beside the numbers). A child handed no bus used to write its datagram into whatever file or socket had the bus's old descriptor number. -
An ASGI WebSocket's disconnect cancelled the application's task (SPEC L21). uvicorn delivers
websocket.disconnectthroughreceive(), and FastAPI's documentation is written against that: its cleanup is anexcept WebSocketDisconnect:after the receive loop, which a cancellation skips, so the departed client stayed in the application's list for ever. The disconnect now arrives throughreceive()(and again on every later call), and the task is not cancelled for it. A handler blocked outsidereceive()learns at its next send, or at shutdown, where the drain gives in-flight tasks a second and then cancels the sockets still running. -
An ASGI response waited for its background tasks (SPEC L22). Starlette runs a response's
BackgroundTaskafter its final body, inside the same call, and the executor answered only when the application returned, so a response with background work was held for as long as the work took, buffered or streamed. It is answered at its final body now, and a client leaving afterwards does not cancel the work. Under the escape hatch (--blocking-threads Nwith an ASGI application) each request runs in its own event loop run, which cannot return early; the response still waits there. -
An ASGI application's error replaced its own error page and lost the traceback; a raising WebSocket closed with 1000 (SPEC L23, L24). Starlette's
ServerErrorMiddlewaresends a finished 500 — with FastHTML'sdebug=True, the traceback page — and then re-raises; the executor answered its ownFailed to process requestinstead and logged one line with no file or line. The application's response now stands, and every application error the executor logs carries its traceback. A WebSocket whose application raises is closed with 1011 ("an unexpected condition", RFC 6455 §7.4.1), not the 1000 that told the client all went well. AClientDisconnectedescaping an application is not logged; a StarletteWebSocketDisconnectthat an application lets escape is, as uvicorn logs it. Andreceive()after a response is answered sayshttp.disconnectat once, where it used to wait for the life of the process. -
An ASGI application's WebSocket close could be answered with a second Close frame (SPEC L15). The executor sent the close as two datagrams, the Close frame and then the socket's end marker. The loop wrote the Close at the first but began waiting for the client's reply only at the second, so an executor descheduled between them had the client's reply read in between and echoed back: a Close after the closing handshake, which a strict client reports as a protocol error. CI saw it twice, once on each OS. The Close now rides inside the end marker, one datagram, so the loop never writes it without knowing the socket is ending.
-
A response's head reached the client rewritten (SPEC A21, K12). A response the application sent without a
Content-Typewent out asapplication/octet-stream: a redirect, a 204, FastHTML's default 404 page, which a browser may then download rather than show. A HEAD the application answered with its body'sContent-Lengthand no body went out withContent-Length: 0(Starlette'sFileResponse, WhiteNoise). And every 204 and 304 carriedContent-Length: 0, from the gateway and from a native Mojo handler alike, which RFC 9110 §8.6 forbids on a 204. A 204 that brought its own length and a body (Django'sCommonMiddlewaresets a length on every non-streaming response) sent both, and the body began the connection's next response. The head now reaches the wire as the application sent it, plus only the framing that is the server's: a buffered body's measured length, and on a HEAD the application's own length. No 1xx or 204 carries a length or a body, whoever set them, and a 304 keeps only a length its application gave. -
A HEAD to a streaming ASGI route received the whole body (SPEC L27). The executor switched a response to streaming at its first chunk whatever the request, so a HEAD to a Starlette
StreamingResponse, which answers HEAD as it answers GET, was streamed, and the loop, which never frames a HEAD, wrote the body raw after the head: all 10,000 bytes of a 10,000-byte route, and an endless stream for ever. A keep-alive client then read those bytes as the start of its next response. A HEAD is now answered at its first streamed body with its head alone, and the application's later sends are dropped. Areceive()it parked before the first body, as Starlette and Django do, is woken withhttp.disconnect, which stops aStreamingResponseand still runs its background tasks; an application that never listens is cancelled if it is still running a second later. A 1xx, 204 or 304 streamed the same way is answered the same way. And a HEAD to a hold, anM0-Holdview under--realtimeor a native SSE route, was held as the stream a GET opens, writing every event and heartbeat after the head: it is answered with its head, and the subscription it made is dropped. -
A chunked request reached an application with
transfer-encoding: chunkedand acontent-lengthat once (SPEC L25). The loop decodes a chunked body before any application sees it, and the length it then added sat beside the client's coding: a contradictory pair an application proxying the request would forward. The body is now described by its length and thechunkedcoding is gone, in the ASGI scope on both bridges and in the WSGI environ. Any other coding, such as thegzipofgzip, chunked, is kept. -
Every chunked upload reached a Flask application empty (SPEC L25). Werkzeug reads
HTTP_TRANSFER_ENCODING: chunkedas a streaming request of unknown length and, withoutwsgi.input_terminated, hands the application an empty input stream, sorequest.datawasb''however much was sent. The environ now describes the decoded body by itsCONTENT_LENGTHand carries noHTTP_TRANSFER_ENCODING. -
A module that calls
asyncio.create_taskat import failed to load with a bareRuntimeError: no running event loop(SPEC L26). FastHTML's first official example does this. m0serve imports an application before any event loop runs, as uvicorn does without--reload, and now says what the module did and the fix: start that work from a lifespan startup handler. The server,--doctorand discovery all name it, with the traceback, and exit 1. Any other import error keeps its own words. -
An ASGI application's lifespan shutdown was skipped when background work outlived the drain. After the drain, the executor gave WebSocket tasks a second and then waited on every HTTP task without bound, so work that never ends -- a response's background task, a poller -- held it until the 5 s thread join gave up and the process left without shutting the application down. Every task now gets 3 s in all, is then cancelled and counted in the log, and a task that swallows its cancellation is named and left behind, never to reach the server again; lifespan shutdown runs after. Work that finishes inside the 3 s finishes (SPEC D11).
-
A HEAD to a WSGI view that streams for ever never answered (SPEC K13). A HEAD took the buffered path, which joins the whole body, so a HEAD to a WSGI SSE view hung and held its pool thread until shutdown; eight of them were the whole zero-config pool. It is answered at the body's first item, with no length, and the body is closed.
-
Two ASGI executor edge cases. A body an ASGI application sent before its
http.response.start, then caught the error for and answered properly, reached the client at the front of the real body; and a stream still winding down on a recycled slot was cancelled a second time by the next connection's disconnect. -
An ASGI application heard 1006 for every WebSocket disconnect (SPEC L28). The code a client closed with now reaches it, as uvicorn passes it: 1000, 1001 for a closed tab, any other code the client sent, 1005 for a Close with no code; 1006 means no Close arrived. And a Close, or a last message, sent in the same instant as the hang-up now reaches the application: the loop closed a socket at EOF before reading what its client had left buffered.
-
An inbound WebSocket message over 64 KB went silent (SPEC I26). A message larger than the channel's datagram could never reach the application or a pool thread, and was parked and retried for ever: no reply, no close, no log. It is refused with a Close carrying 1009, the connection ends on the client's reply, nothing the client sent after it is processed, the application hears 1009, and the log names the size and the limit. On a
--realtimehold this needed the loop to end a socket the handler closed itself:HTTPService.take_ws_closes(). -
A
413for an oversized upload reached curl and browsers, but nothttp.client(SPEC A20). The server refuses a body over--max-bodyas soon as it knows the size, while the client is still uploading, and it closed the socket straight after. Closing with the rest of the body unread makes the kernel send RST instead of FIN, so a client that writes its whole body before reading —http.client, and thereforeurllibandrequests— gotBrokenPipeErrorinstead of the status. Found by a Flask application compared against Werkzeug. The refusal now shuts its write side, reads and discards what is still coming for up to five seconds (RFC 9112 §9.6's lingering close), then closes cleanly. And every error the server answers on its own — 400, 408, 413, 414, 431 -- saidConnection: keep-aliveon a connection it was about to close; it saysclose.--max-body's help anddocs/RUNNING.mdnow say that the server answers over the cap before the application runs. -
The nightly canary could not alert, and one break hid the rest. The 2026-09-02 fix created the missing
nightly-breakagelabel, which was one of two causes: the job declares nopermissions, so the repository's read-only token failed again onGraphQL: Resource not accessible by integration (createIssue)— every run from 2026-08-18 to 2026-09-15 failed and none of them said so, while the free-threaded canary next door had been givenissues: writeon 2026-09-02 and its sibling was missed. It has them now. The build step was also abuild-allsequence, so the first broken package ended the run: the 2026-09-15 canary reportedAtomic[DType...]and_CTimeSpec.tv_subsecand stopped, leaving m0-wsgi, the apps and the whole suite unprobed. It now builds each package itself, carries on past a failure, marks as skipped anything whose dependency broke, and renders a per-package table into the run summary. An opennightly-breakageissue gets a comment rather than a fresh issue every Tuesday. -
page_or_fragment(..., status=422)answered422 OK.textdefaulted to the literal"OK", so a styled error carried the wrong reason phrase unless the caller passed one.textleft alone is now the status's own RFC 9110 phrase (reply.reason_phrase); an explicittextstill wins (SPEC N29). -
mojo buildneeding a C compiler on Linux, and saying so only after the whole compile, is retired as a known issue for an application built withm0: the check runs first and names the fix. Measured on the way: mojo 1.1.0 looks for the literal nameccand nothing else, sogccwithoutccis refused by name, and accthat cannot link is caught by linking one line of C. -
The blobs demo's shapes twisted and popped (
apps/blobs, SPEC N16). Measured on 1.5.0's live stream over 45 s: 64 of 1,758 slot steps twisted, and 69 of 331 frames made a slot appear or vanish whole. The cause was the kernel, not delivery (frames arrived 96–104 ms apart at p5–p95), the server (0.5–1 ms a step) or the browser (60 fps, the main thread about 4 % busy). CSS moves vertexito vertexi, and vertex 0 was each step's topmost point, so when a blob's highest point moved to another lobe every vertex was dragged around the outline. A continuing slot is now turned to the rotation of its outline nearest its previous polygon.data-showdoes not transition a slot it reveals or hides, so now nothing appears or vanishes in one frame:- a split starts as a copy of its parent's outline;
- a drop starts as a seed at 8 % of its size;
- an absorbed blob becomes the merged outline, then shrinks where it was;
- any other shape that ends shrinks to its centre before its slot hides.
The alignment adds about 25 µs to a 312 µs step on an M4 (the same 600-step world, best of five). Measured locally over 60 s after the fix: no twist in 2,588 slot steps. All 40 splits began as their parent's copy, and all 40 disappearances shrank first. The unit tests check every trace against the one before.
smoke-blobschecks consecutive frames on the wire for a twist and a pop.sabotage-blobsgrows from 23 rules to 31, eight of them new: five against the unit tests, three against the smoke.
1.5.0 — 2026-09-19
A host for Mojo applications — workers, loops on threads, a handler pool
and a producer from one serve call — and the first application written
for it, streaming from an image with no Python in it at
blobs.m0serve.dev. The pin moves to Mojo
1.1.0, which lets an application conform to the layer's traits, and an
unmounted --realtime gets the handler pool every other WSGI app gets.
Added
-
The Mojo host (SPEC E21–E25).
m0_host.serve[H, P]is everything in a Mojo application'smainthat is not the application: the listener, the pre-fork shared pages and bus,M0_WORKERSprocesses sharing their accepts, signals armed after the fork, a handler built in each worker byAppHandler.make, and oneProduceron worker 0 whose frames go to every worker's channel, stopped and joined within the drain's 5 s. An application writes two conformances andserve[MyHandler, MyProducer](AppConfig()). A producer numbers its frames from the pre-fork shared word (Publisher.next_id), so a respawned worker 0 continues above what every held stream has seen rather than restarting at 1 and leaving them silent for the pre-crash uptime. Amakethat raises, the handler's or the producer's, is a refusal with 78 before the server listens, never a crash loop, and one worker's refusal ends its siblings (DECISIONS D30).M0_SPAWN_WORKERSis m0serve's and is refused with 78. Gated bysmoke-hostonapps/host_checkand bytest_host.mojo;sabotage-host(46 rules across the host and the pieces it shares) before a release. DECISIONS D26 is retired: theProducertrait is the helper it deferred, and D27 records its choices. The write-up is the-mojo-host. -
A handler pool and route placement in the host (SPEC E26, N19; DECISIONS D31, D32).
M0_BLOCKING_THREADS=Nis served as NMojoPoolthreads behind each worker's loop, on one lane the host marks GIL-free.PoolLane[H]builds the application's handler again on each thread withAppHandler.make,HostContext.threadnaming the instance, so an application conforms once; the host waits for every thread's handler before it serves, and a raising poolmakeis the same 78. A stream begun infuncon a pool thread is refused 409, as on a Mojo mount; one opened on the loop works under the pool.Views.add_readandadd_writetakeon_loop=True, which keeps a route in the table (oneAllow, oneurl_for, one 404) and answers it on the loop with the loop instance's state before it becomes a pool job;m0serve's loop holds no Mojo table and answers such a route one round trip later (D32). The shutdown bounds overlap: the loop stamps the producer's stop word as its drain begins and the pool and producer joins count from that stamp, so a request in flight at SIGTERM beside a long step leaves inside one 5 s bound, not two. -
The Mojo host serves
M0_THREADS=N: N event loops on N threads of one process (DECISIONS D35; SPEC E27–E29). The listener, the shared page, the bus and the accept-share channels are made once and sized by the loop count; each thread builds its own handler withAppHandler.make, its own pool underM0_BLOCKING_THREADS, and runs its own loop; fan-out across loops rides the bus and accepts are shared across the threads (without it, 30 of 32 connections sat on one loop of four).HostContext.worker/workerscount loops and the newHostContext.threadednames the kind, so an application is served by either mode unchanged. New onAppHandlerandViewState:max_threads(), defaulting tomax_workers()— an application whose state lives in one handler refuses loops exactly as it refuses workers, without being asked.M0_WORKERS>1withM0_THREADS>1is refused. No loop takes a connection until every loop'smakehas returned, so a raisingmakeis still a 78 before anything is served. There is no supervisor in this mode: a loop that dies takes the process. Measured against prefork onapps/ramp(poe bench-host-modes): the same throughput and tail on macOS and Linux, and 20–35 % less RSS — so prefork stays what the documentation reaches for first (loops-on-threads). On a Linux x86-64 GitHub runner, threads over workers measured the same throughput and tail and 0.56–0.59x the RSS; at 256 connections on the loop route threads used more CPU per request, 0.86–0.94x workers' per core in every round, which is prefork's side of the decision. -
One views module on two hosts (SPEC N20).
apps/ramp/views.mojois oneViews[Ramp]table built from its mount prefix alone; two adapters a page long build it intom0serveunder--mount /x=mojoand into a host binary.smoke-rampdiffs nine requests across the two (onlyDatediffers), follows a rendered link on each, and measures where a request waits behind a slow view: with the lane full the host's loop route still answers, whilem0serve's waits for a thread, which is D32's divergence recorded on every run. New onbuild-serve:M0SERVE_INCLUDEputs an application's own module root on the include path besideM0SERVE_MOUNT_DIR. The write-up is the-ramp-test. -
Every Mojo app that forked or streamed now runs on the host.
sim_loop(main105 → 19 lines),datastar_counter(56 → 7),datastar_todo(18 → 7) andfragment_notes(15 → 10). The counter now shares its accepts across workers. The todo list now servesM0_WORKERS=2over one SQLite file (SPEC N17), holding the write lock from a change until its frame is published, so a tab's newest frame never misses an older one's change.ViewsApp[S]serves aViewstable with no handler struct (SPEC N18), and an application whose state is per process declaresmax_workers:fragment_notes, which used to ignoreM0_WORKERS=2, now refuses it with 78. -
apps/blobs: a live world the page cannot hold (SPEC N16). The first application written for the Mojo-native stack. A producer thread steps up to sixteen metaballs at 10 Hz — samples their field on a 192 × 192 grid, traces the outlines where blobs merge, and resamples each to 48 vertices in the slot it held last step — and publishes each step as one full-state Datastar frame ofpolygon()clip-paths; a click drops a blob for every open tab. A step costs ~0.2–0.4 ms on an M4. It is the first app that is both aViewstable and a streaming handler, and the first on the Mojo host: itsmainis fifteen lines, andM0_WORKERS=2serves from two processes, a click on either reaching the one producer. Sixteen separate blobs make a 9.6 KB frame, ~96 KB/s per viewer; merged ones make less.uv run poe serve-blobs; gated bysmoke-blobsand the kernel's unit tests, withbrowser-blobsandsabotage-blobs(23 rules) before a release. -
A Mojo application ships as an image with nothing under it, gated on every pull request (SPEC M26, M27; the-demo-in-its-own-image).
deploy/mojo/Dockerfileis one Dockerfile for every Mojo app,APPnaming the directory: the pinned toolchain compiles it in a builder stage, and the runtime stage carries the binary, at/app/serverand PID 1, and the three Mojo runtime libraries beside it, with no interpreter and no toolchain. Its last layer measures the image into/app/about.json: the unpacked bytes, and no interpreter anywhere, failing the build if one is found.apps/blobsreads the file throughM0_IMAGE_FACTSfor a footer and a new/about, and refuses a malformed one with 78.poe smoke-blobs-imagebuilds it from the tree in CI'spid1job, the first x86-64 Mojo build CI makes, and probes it from outside withscripts/mojo_image_probe.py: PID 1, no interpreter, the page's claims, the whole ofsmoke-blobs' main run through the published port, and a held stream ended bydocker stopwith exit 0. On arm64 the blobs image is 102.1 MB unpacked (29.3 MB compressed), 3.0 MB of it the app, against 184.6 MB (60.3 MB) for the Django demo's image; on x86-64 it is 80.1 MB unpacked and holds 20.1 MiB of RSS idle and 24.2 MiB with 100 streams held.deploy/mojo/README.mdnames each figure's unit and architecture. Before a release,poe probe-mojo-imagerecords the floor under every Mojo image (apps/hellofrom the same Dockerfile) andpoe sabotage-mojo-imagebreaks ten of the image's rules. -
D36: the blobs deploy serves one loop. On one core, one loop, two workers and two threads measure the same CPU, delivery and fan-out, on arm64 and on x86-64 (
scripts/bench_blobs_modes.py). Only memory differs: 1.11–1.25x one loop's for threads, 1.79–2.29x for workers. -
The Mojo demo's deploy:
blobs.m0serve.dev, made by each release from this one.deploy/blobs/(the Fly appm0serve-blobs: one machine, one loop per D36, connection-counted concurrency at soft 200 and hard 400) and adeploy-blobsjob indeploy-site.yml. The job builds the release's commit, or a dispatched version's tag, since the image compiles its checkout. It verifies the deploy withmojo_image_probe.py --urland skips a tag older than the deploy with a notice.poe deploy-blobsdeploys by hand. The site links it beside the Django demo. -
An application defines its own frontend vocabulary (DECISIONS D7, retired 2026-09-18; D34; SPEC N21).
Vocabularywas exported but not implementable from outside:Datastarread a private field ofHtmland both built-in conformances called a module-private verb check. New, all exported fromm0_http:Html.open_kind()answering anElementKind(is_form,is_field,is_link), a staticVocabulary.verbs()with a default (STANDARD_VERBS, the five), and the verb check moved into the layer —Html.swapandFragment.swap/.elrefuse a verb outsideV.verbs()beforeV.swapruns, naming that vocabulary's list. A conformance that checks nothing still refuses a typo, and one for htmx 4 allowsqueryin one line.Vocabulary.swap's signature did not change and both built-in vocabularies emit what they emitted, checked byte for byte acrossfragment_notes' four swapping call sites anddatastar_todo's three.poe check-app-vocabulary(insidetest-all) builds an htmx 4 vocabulary from a directory outside the repository and reads its output withhxlint, vendored from hx-flask (MIT; NOTICE, and a recorded-hash guard incheck-docs). The write-up is a-vocabulary-an-application-defines. -
DatastarStream(send_latest=True): a stream of states (SPEC I24). Every subscriber, new or reconnecting, is sent the newest frame for its url at open and then the live feed, never a replay. Without it a page fed by a paused or slow producer stays blank until the next frame. -
A bus publish reports what it delivered (SPEC I25).
publish_to_channelsandBroadcastBus.publishreturn how many channels took the frame, 0 for a frame overBUS_MAX_FRAME, a reserved channel or an over-long name. Each of those was dropped without a word. -
poe test-appsruns an application's own tests fromapps/<app>/test/, insidetest-all;apps/blobsis the first.
Changed
-
The page shell is a trait (DECISIONS D12, retired 2026-09-18).
page_or_fragmenttakes an application struct conforming tom0_http.PageShell— one method,wrap(fragment), called only when a document is wanted — in place of thethinfunction and the separate context argument it took through v1.4.0. The context and the function travel as one value, and a shell that carries no context is a struct with no fields, which is why no second form was kept. The migration is mechanical: the context struct conforms, the shell function becomes itswrapmethod, and the argument goes.PageShellis now exported from the package. It was athinfunction because on Mojo 1.0 an app's conformance to a trait behind a.mojocgot no witness table; that was a package-name-against-directory bug, fixed in Mojo 1.1.0 and pinned on 2026-09-18.check-mojoc-trait'smismatcharm now passes an app's shell through the real API, andwrap_with, the helper that existed only for it, is gone.apps/fragment_notesis on the trait with its wire byte-identical across all nine of its call sites; the write-up is the-page-shell-becomes-a-trait. -
PoolContext,PoolHandler,MojoPoolandJOIN_TIMEOUT_NSimport fromm0_http, notlightbug_http— a source break for a Mojo mount module built throughM0SERVE_MOUNT_DIR(SPEC N14), whose first line becomesfrom m0_http import PoolContext, PoolHandler. The old import is a compile error naming the missing name, at the build that adopts this tree; nothing served changes, and a Mojo import path is outside the served contract stated above.mojo_pool.mojoand the Mojo host were placed in thelightbug_httpfork because Mojo 1.0 emitted no witness table for an application's conformance to a trait behind a.mojoc; Mojo 1.1.0 fixed that, and both have left. The pool ism0_http.mojo_pool. The host is a package of its own,m0_host(from m0_host import serve, AppHandler, ...; it waslightbug_http.host, which no release carried), resolved from source beside the fork — it cannot be insidem0_http, because it calls the event loop,event_loop.mojoimportsm0_http.log, and that resolves through the.mojocthe build would be writing (DECISIONS D33).poe check-host-packagecompiles it whole insidetest-all. The fork's imports ofm0_httpfall from eight to one. DECISIONS D28 is retired. The write-up is docs/notes/the-host-leaves-the-fork.md. -
The pinned toolchain is Mojo 1.1.0 (was 1.0.0). It carries the upstream fix for the
PythonObjectreference leak (modular/modular#6833) and for the witness-table bug that cost a trait its conformance when a package's name differed from the directory it was compiled from. Both were measured in this tree against 1.0.0 as the null case: 1000 operations leak 1001 references there and 0 here, and all four arms ofcheck-mojoc-traitcompile where two were refused. That check was written as a countdown and is now a regression guard whose failure says what the constraint's return would cost. Nothing served changes. What the move cost in source:Atomic[Int64]forAtomic[DType.int64](20 sites),_CTimeSpec.tv_nsecfortv_subsec(2),ArrayforInlineArray(2), aSpan[UInt8]forHasher.update(2), andptr/as_c_string_spanfor the deprecatedunsafe_ptr/as_c_string_slice(7 lines) — the warning ratchet's floor stays 0. The ROADMAP known issue for the leak is retired, and DECISIONS D7, D12 and D28, each decided on the witness-table bug, are retired in this release (the entries above). That bug had been recorded here as a language limitation for three weeks (PoolHandler2026-08-28,PageShell2026-09-10); the error named the cause all along (trait 'src::fragment::PageShell'), and a-trait-and-a-directory-name records how it was found. Free-threadedPyObjectlayout (modular/modular#5726) is NOT known to be fixed — it needs a 3.14t interpreter, sopy-canaryanswers it. The write-up is docs/notes/the-pin-moves-to-1-1-0.md. -
One pre-fork preparation for both hosts (SPEC E24).
m0_http.preforkmakes the shared page, the bus and the accept-share channels, exports each by descriptor, and adopts all three in a spawned worker;m0serveand the Mojo host both call it, wherem0serveused to keep its own copy and the host a second one. Nothing served changes: the flags, theM0_*namesm0pubreads and an exec'd worker's environment are as they were. The Mojo host's page is now file-backed and exported, asm0serve's already was, and a spawned worker handed no page descriptor is refused rather than served from an address that is not its own (the oldm0servepath skipped the adoption silently and would have bound accept sharing to its parent's address). Six ofsabotage-host's rules run againsttest_prefork.mojo. -
An unmounted
--realtimegets the zero-config handler pool. It used to turn the pool off, som0serve app:application --realtime— the Quickstart's own command — ran every view on the event loop, and one slow view stalled every held stream (measured on textshelf at 0.12.0: a 1 543 ms fast-path p50 against 0.3 ms with a pool).--doctorreportedblocking_threads: 0withblocking_threads_source: defaultand nothing else said so. The reason was the demo's smokes, which were written against the single loop; holds have been taken on pool threads since 0.12.0 for SSE and since 2026-08-26 for WebSockets, and the mounted--realtimealready took the pool. Now the flag composes with the samemin(cores, 8)default every WSGI app gets, the smokes that ran the single loop run pooled, and--blocking-threads 0(orM0_BLOCKING_THREADS=0) is that shape by name — which is how SPEC E20's gate serves it. Found running desk on m0serve 1.4.0. -
The live demo shows the bus crossing it exists to demonstrate. Each line names the worker that published it and the worker that delivered it to this tab, marks the ones that crossed between them, and the page counts them; before, a visitor had to compare two numbers in different places, and a tab whose connections and publishes all landed on one worker looked the same as one they had crossed. The page links to a second tab of itself, the visitor cookie is
HttpOnlyandSecureover HTTPS, and the CSP allows the inline script and stylesheet by sha256 instead of'unsafe-inline'.scripts/demo_probe.pyasserts each, sabotaged eight ways against a local server on the 1.4.0 wheel.
Fixed
-
A JSON number read from a request body could kill the server (SPEC G14).
m0_core.json_parse.parse_json_numbercut the number out with aStringslice, which asserts a codepoint boundary, so a body such as{"x":1<0x80>}trapped the thread that read it. Nothing in the tree called it untilapps/blobs' drop view; the cut is now a byte-span slice. -
Two processes opening one fresh SQLite file could not both get WAL (m0-sqlite, SPEC O1).
openswitches a new file out of the rollback journal withPRAGMA journal_mode=WAL, and SQLite answers the loser of that race withSQLITE_BUSYat once, without consulting the busy handler set the line before, soopenraiseddatabase is locked. It now retries the switch withinDEFAULT_BUSY_TIMEOUT_MS. About one pair in two lost the race on an M4 (test_two_processes_open_one_fresh_databaseforks twelve), andapps/datastar_todorefused 29 of 30 two-worker starts once the host stopped hiding the raise behind a respawn; the todo list also creates its schema insideBEGIN IMMEDIATE. -
A supervisor told to stop no longer respawns a worker that fails its drain (SPEC D10). After a SIGTERM to the supervisor alone — what
docker stopsends — a worker that exited non-zero, or died of any signal but the one forwarded, was replaced. Nothing ever signalled the replacement, so the supervisor served it until SIGKILL. The supervisor now lets it go and exits 1 once the rest are gone.m0serve --workers Nand every Mojo app that forks share the supervisor. Found while gating the Mojo host. -
Ten unchecked bodies in the lightbug fork, one of them a stack write past the end. Mojo type-checks method bodies lazily and
build-httpprecompilessrconly, so the fork's 56 files were checked only where an app instantiated them — and nothing compiled them whole.poe check-fork-packagenow does, insidetest-all:IntwhereInt32was wanted at_getsockopt,_recvfromand_writev, asocklen_tfromsize_of, an origin mismatch in the deadProvisionPool.get_ptr(removed), four hand-written copy/move dunders in the cookie types (removed, the compiler derives them), andgetsockoptlanding the kernel's reply in a one-byte allocation it had described assize_of[Int]()bytes. All ten sat in paths nothing instantiates, so no shipped behaviour changes; the gate is what stops the next one. -
poe deploy-siteandpoe deploy-demofailed without a version argument. Their default, the tree's own version, was a Python one-liner whose\"escapes TOML consumed, leaving a syntax error; a version given on the command line never evaluated it, which is how it went unnoticed. Both now read the version with thegrep/sedpair another task already uses, as does the newpoe deploy-blobs. -
The live demo's deploy built from main, not from the release it pinned.
deploy-site.yml'sdeploy-demojob checked out the default branch and pinned the release's wheel, sodeploy/demo/Dockerfile, the demo application and the probe that verifies the deploy came from whatever had merged since the release -- or, for therelease/v*branch form, from a main that might not hold the release's commit. It now checks out the release's own commit (head_sha) after a release and the version's tag on a dispatch, and refuses to deploy when the tree's version is not the pinned one. The docs site's job builds from the release's commit after a release too; a dispatch still renders the ref it ran on, which is how prose merged after a release is published. -
The docs promised
Last-Event-IDreplay on a WSGI hold, which keeps no journal. RUNNING.md and them0pubdocstring said a numbered frame was "covered by replay"; the plainSSERegistrya hold subscribes to only suppresses an event a reconnecting client already has, and events published while it was gone are not delivered — an application that trusted the sentence and dropped its own catch-up lost messages every time a phone slept. Both now say suppression only, README's "journal-deep" limit names the hold, the Quickstart says to keep a catch-up path, and a Known issue records what a journal would take. Two more from the same notes: RUNNING.md read as if SSE heartbeats were off untilM0_SSE_HEARTBEAT_MSwas set (they default to 15 s, so its 25000 example made them rarer), andm0pub.publishdid not say thatdatais a payload rather than a frame, so pre-framed text was framed again and reached the client asdata: data: ...with nothing logged; its docstring now says so and points atpublish_frame. Not detected at run time, because a payload may legitimately begin withdata:. Found running desk on m0serve 1.4.0. -
The live demo's WebSocket status line described another tab's socket (
apps/demo, SPEC M17). It updated on any message that arrived over a WebSocket, and every tab's socket messages reach every tab on the visitor's channel, so after tab A sent one, both tabs said "slot 1 on worker 647 answered a message" -- tab A's socket, measured in Chromium and WebKit against demo.m0serve.dev on 1.4.0. Each tab now sends{"text", "tab"}with a random tab id, the view echoes the id, and a tab learns its socket's worker only from its own echo. A client that sends plain text, or JSON that is not the envelope, still has it broadcast verbatim. -
apps/sim_loopdelivered its steps to worker 0's streams only underM0_WORKERS>1(SPEC N15). The simulation thread published tobus.write_fds[0]alone, while the app's own comments said every worker's loop received the frames; its gate ran one worker, where the first channel is every channel. The thread now holds every write fd. The app also shares accepts (SPEC E16) — without it one worker takes nearly every connection — and/eventsnames its worker inx-worker.smoke-sim-loopgains a two-worker phase: four held streams must span both workers and each carry the steps, so a run that lands them all on one worker fails as vacuous rather than passing. The reference for periodic work off the loop is what a Mojo application copies, which is why this is a fix and not a footnote.
1.4.0 — 2026-09-15
Mounts that start are mounts that can be served, a Mojo mount that uses every thread it is given, a pool-less loop that lets an application's own threads run, and a child process that can publish without crashing.
Added
-
A reference for periodic work that does not run on the event loop (SPEC N15,
apps/sim_loop).HTTPService.tickfires on the loop thread, so what it costs is its duty cycle — work over period — and that transfers into p99 about one for one; the docstring now says so, and this is the other half: where the work goes instead, as an application and a gate rather than a paragraph. A 4 Hz simulation runs on a thread of its own and publishes each step through theBroadcastBuswithskip_worker = -1, which the loop drains intosse_peer_frame. Three things in it are easy to get wrong elsewhere: the bus is created unconditionally, at ONE worker too, because here it is the thread-to-loop channel rather than only worker-to-worker (apps/datastar_counterjoins it only above one worker and reads as though that were the rule);skip_workeris-1, since nothing has queued the step locally and passing the worker index publishes to nobody without saying so; and the thread is joined within a bound,pthread_joinhaving no timeout, so an unbounded join is a SIGTERM that does nothing until SIGKILL.M0_SIM_ON_LOOP=1moves the identical step ontotick— same work, same cadence, same publish, only the thread differs — and is both the A/B knob and the gate's negative arm: a trivial request measured 0–1 ms off the loop against up to 200 ms on it. Only a request that lands in a step's first half waits the gate's 100 ms, so the on-loop arm's margin is the probe's sample count: it took three samples and failed 19 of 60 local runs once their phases were random, as a slow runner makes them (it failed a CI run at 98 ms); it takes 24 at random gaps.poe smoke-sim-loopalso asserts the step ids arrive contiguous (the bus is best-effort; at this cadence it must not drop) and that SIGTERM with a step in flight still exits 0. Building it found two defects of the class it exists to prevent: the on-loop arm silently did nothing,ticknever firing unlessM0_APP_TICK_MSis set — zero steps and a fast/now, reading as "the loop coped fine" — and the first probe never joined its thread at all. D26 records the framework helper deliberately NOT built: one application is not evidence of which cadence, shutdown and publish choices are right. -
A
SessionStarthook, so a fresh container can build and test the tree (.claude/hooks/session-start.sh). Three things are missing from a bare Linux container and each fails in a way that reads as a code problem rather than a setup problem. Without patchelf,poe build-serveaborts the whole sequence as the LAST step oftest-all, after every test has passed, and nothing earlier in the run hints that the tree is fine. Withoutuv syncthere is nomojoat all, since the toolchain comes from the venv. And the.mojocartifacts are gitignored, so a fresh clone has none and every cross-package import fails to resolve untilbuild-allruns — which CLAUDE.md notes appears as unresolved imports in the editor, i.e. indistinguishable from a broken checkout. The hook is best effort throughout and always exits 0: a session that refuses to start because apt could not reach the network is worse than one that starts with a loud note saying what is missing, and the failure this exists to prevent is a silent one. Remote-only (CLAUDE_CODE_REMOTE), so a developer's own machine, and macOS, which needs none of it, are untouched.
Fixed
-
A child process can publish from a view without crashing (#322, SPEC I23). The bus descriptors survive
execbut the event-id page did not:M0_SHARED_ID_ADDRis an address in the parent's memory, and a child that inherited the environment took an id there on its first publish. On Linux that was SIGSEGV. On macOS the address was often mapped in the child, which incremented eight bytes of its own memory and published the result as an event id; a subscriber that had seen a higher id then silently dropped the frame. The page is now file-backed in every mode and exported asM0_SHARED_ID_FDtoo, and carries a magic word.m0pubnumbers only from a page it has verified: mapped from the descriptor, or at an address the kernel reports readable. Anything else publishes unnumbered, with one line on stderr saying why. The newm0pub.child_fds()is what to pass aspass_fds, and a child given it publishes numbered frames from the same counter, soLast-Event-IDreplay covers them. A server that cannot file-back the page (no/dev/shm) starts anyway and says so, except under--spawn-workers, where that was already fatal. -
bench-mojo-mountruns on a fresh clone, and its artifact says what it measured. Four defects, each confirmed onmainbefore being fixed.build-servelinked four packages through.mojocfiles a fresh clone does not have, so that bench — and every smoke that depends onbuild-serve— failed withunable to locate module 'm0_http'untilbuild-allran by hand; CI never saw it because every job runsbuild-allfirst. A MISSING artifact is now built in dependency order, the[ -f ] || uv run poe build-ffiidiom the tree already uses, while a stale one is not, so a smoke does not pay for the chain on every call. The bench leftmojo_mount_bench.logat the root unignored, so every run stampedgit_dirty: true— the 2026-09-10 artifact among them — and about thirty other root logs written by tasks were missing from the per-name list too; one/*.logreplaces it. The recorder stamped no machine conditions: macOS artifacts now carry power source, Low Power Mode, pmset's thermal report, the performance / efficiency core split, memory and the OS build, every artifact numpy's version, and Linux ones memory. And "per core" had two definitions — the headline (1.65x / 1.41x) was the median of each round's ratio while the artifact's medians block divides median rps by median cores (1.61x / 1.39x), and one table mixed them — so artifacts now carrydefinitions,medians.*.rps_per_core_roundsbeside the existing figure, and this bench acomparisonsblock with both ratios per selectivity. -
A benchmark artifact without its comparator rows is refused, and the Mojo mount's table is rendered. The mixed workload recorded for this release under the free-threaded swap had no Granian row: the swap's venv is built from the default dependency groups, Granian is the
benchgroup, andbench_mixed_workload.shskipped the row without a word. The drift check then found no comparator in common with the previous artifact and passed, a leniency meant for older artifacts, so a table would have lost its reference row with its contamination check vacuous.render_bench_docs.pynow refuses a rendered kind's newest artifact that holds none of its comparator rows, and the bench refuses to start without Granian (BENCH_NO_GRANIAN=1to mean it). docs/SERVER_PERFORMANCE.md renders the Mojo-mount table frombench-mojo-mount's artifact, printing both definitions of per core and naming them, and every rendered table's environment line now carries the machine conditions the recorder stamps. -
Stopping the WSGI handler pool right after starting it no longer hangs. Each
BlockingPoolthread registered on its lane as the first act of its own body.stoppills registered threads on their own wake channels and sends the remainder to the lane socket, so a thread that registered after a racingstopparked on its own channel while its pill sat on the lane socket. Its join then waited out the 5 s bound and the process left naming a thread it had abandoned. The Mojo pool had the same body and the same fix earlier in this release. Threads are now registered instart, on the spawning thread, before any exists. The window in a server is small, sincestopfollows a loop that has served and returned; SIGTERM during startup on a loaded host was the way in. The newtest_blocking_pool.mojocounts the threads the momentstartreturns and stops at once: 0 counted and a 5,072 ms join before, 4 and about 70 ms after, 10 runs of 10. -
M0_POOL_SPIN_USdoes what its documentation says. It was documented from 0.19.0 (2026-09-07) as overriding the handler pool's 10 µs idle spin (POOL_SPIN_NS), in CLAUDE.md, NOTICE, this changelog anddocs/notes/pool-tail.md, whose measurements used it. But the patch that read it was never committed, so setting it changed nothing. It is now read once when the pool is built, likeM0_POOL_WAKE_AGE_US. A value that does not parse, or a negative one, keeps the default. With one handler thread at four connections, lane wakes went from 39k by default to 93k at0and 4 at1000. -
Mounts under
--threadsare tested where they can run (SPEC M23).smoke-hybrid's--threadsphase served ASGI mounts, which a free-threaded build refuses with exit 78 (modular/modular#5726). So it could not pass on the only interpreter where it ran, and no scheduled job ran it. It is nowpoe smoke-mounts-threads, phase G of the weekly free-threaded canary. There, Django and Flask mounts serve with their prefixes under--threads 2, a slow Django mount does not stall the Flask one (hybrid_isolation.pytakesISOLATION_FAST_PATHto measure a WSGI mount), SIGTERM ends both loops, and the ASGI refusal is pinned. On a GIL-enabled interpreter the task checks only that a mounted--threadsserver is refused. -
A Mojo mount no longer slows down as its handler pool grows (SPEC M22).
MojoPoolthreads never registered on their lane, so the pool's elastic wake rules read the lane as "every thread parked" whenever its one awake thread was busy, andsubmitwoke a sibling on nearly every request. The reservation of per-thread wake channels was also sized for the WSGI pool alone. On/native/probeat 16 connections beside a WSGI mount, the zero-config eight threads served 104k rps on 2.04 cores (202k lane wakes); with one thread, 132k on 1.42. Mojo pool threads now register, park on their own channels and unregister on exit, and_serve_offloadedreserves records for every pool before starting any. Eight threads now serve 133k rps on 1.35 cores with 61k wakes, which beats the WSGI route beside it per core. -
A Mojo mount's compute route runs on all its threads again (SPEC M25). Registering the Mojo pool's threads (M22, above) put its lane under the elastic rule's stall check, which leaves a ring alone while its pop count moves: a sibling woken beside a draining lane only queues for the GIL. A Mojo mount's threads never take the GIL, and the check held its lane to one of four threads —
bench-mojo-mount's search served 15k rps on 1.2 cores where the unregistered pool served 42k on 4.2. Waking eagerly on every push restores that and costs the trivial/native/probe21 %, so only the stall check changes: on a lane whose threads never attach, a job that has waited past the idle spin (10 µs) since its push gets a parked sibling, moving ring or not. The search serves 43k rps again, and 76k at a quarter selectivity where it had fallen to 37k. The lightest routes pay for it: the probe gives up about 2 % (195k against 199k) and the lightest search about 4 %, each on more CPU.M0_POOL_PARALLEL=0restores the GIL rule on every lane. -
A path no mount claims gets the server's own 404, whatever the first mount is (SPEC M21). The loop sends a path that matches no mount to the first mount's lane, and only a WSGI pool thread checked the path again. So on a server with no root mount whose first mount was ASGI,
GET /zzzgot that application's answer (a 200 from the fixture app, and a 403 for a WebSocket upgrade), a lone ASGI mount did the same, and a Mojo mount listed first answered with its own router's 404 rather than the server's. The miss is now decided on the loop, before a mount's lane is chosen, against every mount's prefix includingmojoandholdmounts. The check covered WSGI-first servers all along, which is the only shapesmoke-hybridtested; its new phase 2b tests the other three. -
A mount set that some mount cannot be served in is refused instead of started half-served, and one that needs handler threads gets them (SPEC M20). Four configurations used to start and then answer wrongly or not at all:
- a
--mount PREFIX=mojowith no handler pool (--workers N,--blocking-threads 0) served inline, where the loop knows only the Python mounts, so its requests were answered by the root application; - an ASGI mount beside a Mojo mount on zero config gave the Mojo mount no threads, and its requests hung;
- more WSGI mounts than handler threads (
--blocking-threads 1with two, or zero config on a one-core host) left a mount with no thread, and its requests hung; - an ASGI mount beside a WSGI mount under
--workers Ngave the WSGI mount no thread, with the same result.
Every mount now gets at least one thread from the default pool. Unless
--blocking-threadsis given, that default also applies under--workers Nor--threads Nto a mount set that cannot run inline: a compiled mount, or an ASGI mount beside a WSGI one. So--workers 4 --mount /=django --mount /native=mojonow serves both.--workers Nwith only WSGI mounts keeps its meaning of no pool.An explicit
--blocking-threadsis used as given. If it's below the compiled mounts of a kind, the server exits 2; if it leaves a WSGI mount without a thread, it exits 78 once detection has run. A compiled mount under--threads, which was never served, also exits 2.The three existing mount refusals (only compiled mounts, a hold mount without
--realtimeorM0_GRANT_KEY) moved before the fork. Under--workers Nthey ran in every worker and ended in "5 rapid crashes" and exit 1, and--doctoranswered 0 for the first of them.mainand--doctornow refuse through one function.smoke-doctorholds nine mount rows to both agreement and a named exit code. - a
-
A thread a WSGI application starts runs while the server is idle, with or without a handler pool (#310, SPEC E20). With no pool — an unmounted
--realtime,--blocking-threads 0, or--workers Ngiven explicitly — the event loop calls the application itself, and it waited for I/O still holding the GIL its thread has held sincePy_Initialize. Athreading.Threadthe app started, or anasyncio.runinside one, then ran only when a request happened to run some Python: measured at 1 tick in 1.5 s idle where 150 were due, and an agent turn publishing progress withm0pub.publishnever got past its first line. The loop now releases the GIL around each wait and takes it back before touching the application, the shape--threadsalready had. Throughput is unchanged (hello-world at 16 and 256 connections, A/B within run-to-run noise). On 1.3.0,--blocking-threads Nwith any N above 0 avoids it.poe smoke-app-threadsserves an app with a ticking thread in all three shapes on both CI legs and counts its ticks over an idle window. -
The weekly free-threaded canary hung on Linux in every run that reached its last phase since 2026-08-23, and the hang was the gate's own shell. Phase 2 of
smoke-blocking-threads(a handler pool behind each--threadsloop, run only on 3.14t) waited for its two slow requests withwait $(jobs -p | grep -v "^$pid$"). poe runs shell tasks withsh, which is dash on Linux, and dash gives a command substitution's subshell no job table — so the list was empty, thewaitwas bare, and it waited on the server until the phase watchdog fired. macOS'sshis bash, which answers the list, so the macOS leg passed and the server was never at fault: reproduced locally by running the task under/bin/dashagainst 3.14t (hung at the same line, SIGALRM at the deadline) and green in 20 s with the pids recorded, as phase 1 already did. The phases proving SPEC E5 and L18 passed, but the run both rows cite as their weekly evidence was red for three weeks, whichpoe milestonescannot see.check-docsnow refusesjobsinside$(...)in any task, with selftest cases that put the defect back into the committed file, becausedash -nparses the line without complaint.
1.3.0 — 2026-09-13
A PostgreSQL binding, a second door onto the bus, and a mount an application brings itself.
Added
-
A gate on the
_ = xkeep-alives at the FFI sites. Roughly thirty of them end a sequence that hands a buffer's address to C, and nothing proved they still worked.poe check-keepalive-barrier(insidetest-all) compilesscripts/keepalive_probe.mojoto LLVM IR and reads four exported bodies: with the line, the allocator free lands after the call; without it, before — which is the measured hazard, and the half that keeps the gate evidence rather than ceremony. The probe also records what is NOT at risk, since only an owning value is: a stack local whose address escapes is kept alive by LLVM without help.poe sabotage-keepalivereverts each of the probe's own rules and insists the check reports every one. -
An application's own Mojo mount, without copying
m0serve.mojo(SPEC N14). The demo mount moved out of the entry file into a module,m0serve_mount(packages/m0-wsgi/mount/), andpoe build-servetakesM0SERVE_MOUNT_DIRto build the same entry file against an application's own directory instead, plusM0SERVE_OUTto name the binary. The directory replaces the default rather than joining it: with both on the include path the first root wins silently, measured as a clean build serving the demo. -
A Postgres
NOTIFYreaches a held stream (SPEC I22).--pg-listen URL(M0_PG_LISTEN) holds oneLISTENon worker 0 and publishes what arrives onto the broadcast bus, so a writer that cannot reach the datagram bus at all — a trigger, a cron job, a management command,psql— reaches every subscriber with an id, an event type and its data. The payload is three JSON string fields, and one present as anything else — the object'data', row_to_json(NEW)makes — is refused and counted rather than delivered as an empty event (m0-core gainsparse_json_stringandhas_json_field, which can tell a real""from a wrong type);m0pub.notify_sqlbuilds the statement for a caller that already has a database cursor, with no driver imported into a stdlib-only module. Refused without--realtime, which is what creates the bus, and--doctorreports both that refusal and which libpq it found. Nothing is resolved from libpq when the flag is absent — the server binary's dynamic dependencies are unchanged, which is what keeps the wheel installable on a machine with no PostgreSQL client. Started in both execution modes: worker 0 under--workers, and once per process under--threads, where the bus is one channel per thread. Refused on macOS wherever a worker is forked —--workers N, and--reload, which supervises even one — because libpq's connect reaches Kerberos through GSSAPI and Objective-C aborts a forked child;--spawn-workersis the escape, as it is for Core ML, and composes with--reload. The SSE line splitter and frame decoder a payload passes through slice by bytes now (SPEC G14): adatafrom aSQL_ASCIIdatabase with a continuation byte after a newline trapped the listener's thread on worker 0. The listener drains once after connecting and after every reset, so a notification libpq read during theLISTENround trip is not left waiting for the next one to wakepoll. A host with no libpq exits 78 naming every path tried, rather than serving with no listener — held on the wheel's own binary, since its users have neither a toolchain nor a PostgreSQL client. -
m0-postgres, a PostgreSQL binding over libpq (SPEC O6–O16). A sibling ofm0-core,m0-httpandm0-sqlitethat imports nothing else here and links nothing: libpq is opened withdlopenat run time, so no binary in this repo —bin/m0serveand the wheel included — carries a libpq dependency, and an absent library is one error naming every path tried.Connectionowns its handle,Resultowns a completePGresultand so can outlive its query,Paramsbinds positionally with an explicit OID per value, and every error carries a SQLSTATE thatsqlstate()recovers.open()applies a connect timeout, a statement timeout,client_encoding=UTF8, an application name, and TCP keepalives of 30 s idle, 10 s interval and 3 probes with a 60 stcp_user_timeout— libpq's own keepalives use the OS's timings, measured at 7200 s idle, so a listener behind a NAT that dropped its idle connection went deaf for two hours — merging rather than appending so each stays overridable;open_readonly()adds a read-only transaction default; every URL is redacted before it reaches an error, a log or the doctor, including the onesredactcannot parse — an unencoded/,?or@in a password, a key/value string with quoted or spaced values, a keyword libpq does not know — which come back masked whole, with libpq's own message beside them withheld, because libpq quotes what it cannot parse.sslpasswordandoauth_client_secretare masked aspasswordis.LISTEN/NOTIFYis supported, including restoring subscriptions across a reset. No pool, no retry, noCOPY, andnumeric, dates, intervals and arrays read as text — each a deliberate absence, with the reason in the README.Three Mojo 1.0 findings are recorded in
lib.mojoand pinned bytest_lib.mojo, all found by crashing: adlopenhandle held apart from the pointers loaded from it is closed at its last mention, athinpointer field cannot be called astable.field()from outside the struct that holds it even though its address is unchanged, and aResultthat reached libpq through its connection's address faulted once that connection was gone. libpq is now pinned withRTLD_NODELETEonce opened and aResultholds the entry points it calls by value, sovar rows = db.query(...)with no later use ofdbreads correctly (SPEC O16) — before the fix it was a segmentation fault on the first read. -
m0-sqlitegets its rows (SPEC section O, O1–O5). The storage packages had no capability rows at all, so nothing in the sheet noticed the three invariants CLAUDE.md calls "look like bugs and are not": thatopenraises rather than delivering less than the WAL concurrency it promises, that aStatementoutlives theConnectionthat prepared it because every close path usessqlite3_close_v2, and that error text is trusted only whensqlite3_errcodecorroborates the code being described. Each is now a row against the test that already proved it, besidem0_array's borrow safety and the todo demo's persistence across a kill and a restart. No behaviour changed; five gates that were running are now recorded, and the sheet's own sabotage suite covers them. -
A Datastar form, end to end (SPEC N12). The todo demo renames a todo in place:
Fragment[Datastar]on the<form>emitsdata-on:submit__prevent="@post('/edit/7', {contentType: 'form'})", the new/edit/:idroute reads the field withform(req)and refuses any other body with a 400, and the broadcast morphs the renamed todo into every tab.smoke-todoposts the form and greps the frame and the page's wire spelling on every pull request;poe browser-datastar-form, a pre-release step, drives the same form in Chromium and records what the pinned bundle sends — the form's fields urlencoded, and for a bound field's own action the signal store as JSON, which confirms D21. The lastplannedrow on the layer is now N13. -
Sessions and CSRF behind a login (SPEC N13).
m0_http.sessionsigns, verifies and expires a stateless session cookie —v1.<kid>.<exp>.<subject>.<tag>, HMAC-SHA256 over the rest, the tag compared in constant time, the expiry against the host clock, a key ring so a rotation ends every session under the key it retires — and derives the CSRF token a form carries from that session's own tag, so neither needs a store.session_cookie_linebuilds theSet-CookieforResponseCookieJar.add_rawwithHttpOnly; SameSite=Lax; Path=/, andvary_on_fragment_headersis now exported so an application's own redirects name the headers that chose them.apps/fragment_notesis the worked application: one user fromM0_NOTES_USER/M0_NOTES_PASSWORD, signing withM0_NOTES_KEY, refusing to start (78) without a key or a password, private notes, a guard that is an early return, and a login that answers 303 to a navigation and 401 carrying the login fragment to a swap.smoke-fragment-notesasserts every arm on the wire with the forgeries signed byscripts/notes_session.py;test_session.mojoholds that issuer's vectors;poe sabotage-notes-login(pre-release) reverts five rules and insists the gate goes red for each. Retires D15; D24 and D25 record what it deliberately is not.poe browser-notes-login, a pre-release step, drives the same flow in Chromium: htmx 2.0.4 would put aDELETE's fields in the query string, so the shell narrowsmethodsThatUseUrlParamstogetand the run records that the token travels in the body. Section N has noplannedrows left.
Changed
- m0-postgres checks each libpq symbol where it loads it, so the two
cannot drift.
PgLibwalked a hand-writtenrequired_symbols()list before loading anything, becauseExternalFunction.loadaborts the process on a missing symbol — but that list was a third source of truth beside the declarations and the fields, and nothing compared them.test_every_symbol_the_table_loads_is_checked_firstasserted the list was plausible, never that it matched what is loaded, so a 33rd entry point added without its list entry passed every gate and would have aborted on the first host whose libpq lacked it — the one failure the list existed to prevent. A_checked[name, T]helper now takes the symbol from the declaration it loads, and the list is gone along with its export fromm0_postgres. The replacement test drives both arms of the refusal, whichPgLib.open()succeeding cannot show; reverting the check makes it abort rather than fail, the same evidentiary shape as the two crash regressions beside it.
Fixed
-
The Linux container could not build
m0-wsgi, sostress-poolandbench-linux-conclusions— two pre-release gates — failed before their first round. The container's build list never gainedpostgresafterm0-wsgibegan importingm0_postgresfor--pg-listen, and CI cannot see it because there is no such container there. Found by the 1.3.0 release run; fixed in the four places that name the list, including the setup script that creates the container, without which a fresh one could not be built at all. -
A todo whose text is not UTF-8 no longer kills the server (SPEC G14).
m0-datastar'ssplit_data_linesis a deliberate copy ofm0-http's SSE line splitter — the wire format stays dependency-free — and kept the[byte=a:b]slice after the original was fixed. What reaches it is a rendered fragment, which is where an application puts request data, so a todo readinga\n<0x80>b(HTML escaping touches neither the newline nor the high byte) cut on a non-codepoint boundary and trapped the loop thread: one unauthenticated POST took down the whole process and every connected tab. Both cuts are byte-span slices now, pinned by a unit test and bysmoke-todoposting exactly those bytes over a socket. -
poe check-milestonesread the server soak's version as the firstm0serve X.Y.Zanywhere in its half of the record. A later section's heading started carrying a version too, so the sabotage that blanks the headline began finding that one instead and stopped being caught — and the report would have gone on printing a version the record no longer named. It reads the record's ownLast run ... againstheadline now, which is what the layer's half already did. Nothing on the wire changes; the guard does.
1.2.0 — 2026-09-12
A stream the server holds against a grant, and a process for the application layer.
A Django or Flask view that has decided who may read a channel can now
hand the browser a signed URL into --mount PREFIX=hold, and a Mojo pool
thread holds the stream without touching the interpreter, verifying the
grant alone. That rests on the tree's first cryptographic primitive,
HMAC-SHA256 in m0-core gated by published vectors, and on the piece
under it: a view on a Mojo mount taking the same M0-Hold a Django view
takes. Beside them, the application layer is tracked the way the server
is — a milestone poe milestones computes, a ledger of standing
decisions, and planned rows naming the application that pulls each.
The served contract grows and does not break: one new mount kind
(--mount PREFIX=hold) and three environment variables (M0_GRANT_KEY,
M0_GRANT_KEY_PREV, M0_GRANT_COOKIE), all additive. No existing flag,
header or default changed, so every current deployment upgrades by
version alone.
Added
- A grant-verified hold mount (SPEC I21):
--mount PREFIX=hold. The application keeps every authorization decision in its own views and hands the browser a signed stream URL into the mount —m0serve.grant.stream_url(prefix, channel, session=request.COOKIES.get("sessionid"))— and the mount holds the stream on a Mojo pool thread that never touches the interpreter, verifying the grant alone: HMAC-SHA256 in constant time, expiry against the host clock, the session cookie the browser sends against the binding the issuer put in.M0_GRANT_KEYis the one secret, read by both sides;M0_GRANT_KEY_PREVrotates it;M0_GRANT_COOKIEnames the cookie (sessionid). A refusal is a 401 sayingexpired(fetch a fresh URL) orinvalid(stop), with its reason. The mount refuses to start without--realtimeor the key, and--doctoragrees.smoke-hold-mountis the gate;test_grant.mojoverifies grants the same issuer signed. The issuer is stdlib-only Python in the wheel and, byte-identical, inapps/django_realtime. - SHA-256 and HMAC-SHA256 in
m0-core(SPEC G15), with a constant-time tag compare. The first cryptographic primitive in the tree:Sha256streams anddigestleaves the state intact, soHmacSha256absorbs its padded key once per thread and finishes each message from a copy — the shape a grant verifier on a pool thread wants, with no allocation in the state. Gated by FIPS 180-4's examples, every split of one message across the padding boundary, and RFC 4231's seven cases; every expected value was produced by CPython'shashlibandhmacrather than transcribed.wyhash64was never a MAC (D15); this is what a signed grant for a Mojo-held stream, and later a signed session cookie, are built on. - An SSE hold from a Mojo mount (SPEC N11). A view on a Mojo mount
returns the two instruction headers a Django view returns,
M0-Hold: streamandM0-Channel, and its pool thread does what a WSGI pool thread does with them: rewrites the response into the stream's head, sends the loop the samehframe before completing, and completes with the head. The loop subscribes the slot in the registries it drains, so a publish from Python —m0pub.publish()from a Django view — reaches the Mojo-held stream with its id, the client'sLast-Event-IDis honoured, heartbeats keep coming, and the slot is released when the client leaves. Only under--realtime; without it the head is served as an ordinary response, as a Django hold view's is. A streaming response that is not a hold is still refused from a pool thread.smoke-mojo-mount-holdis the gate,test_mojo_poolthe unit form, andpoe sabotage-poolreverts both the send and the refusal. The hold module moved into the fork (lightbug_http/hold.mojo, fromm0-wsgi/src/hold.mojo) so the WSGI handler and the Mojo pool share one copy;m0_wsgistill exports every name it did. - The application layer has a milestone, a ledger and planned rows.
poe milestonesprints a third milestone beside beta and 1.0, computed from SPEC section N the way those are from the sheet: no rowimplemented, everyplannedrow resolved, and a soak on the layer — an application outsideapps/running onViews/Fragment, recorded indocs/REAL_APP_VALIDATION.md's new application-layer section. It reads NOT MET, which is the honest state of a layer proven by demos. 1.0 now countsplannedrows outside section N only, since it shipped before the section existed. Three rows wereplanned(N11 streaming from a Mojo mount, N12 a Datastar form end to end, N13 a login), each with a ROADMAP heading naming the application that pulls it and the gate that will verify it; N11 was built in this same release, above.docs/DECISIONS.mdis the ledger of standing decisions, D1–D21 at first, D22 and D23 joining with the two hold pieces above, each naming the note that argues it and what would retire it;check-docsfails when a row's note does not exist, its condition is empty, its id is repeated or out of order, or itssuperseded bynames no row, and--selftestreverts each rule against the page.
Fixed
-
Dependabot PRs auto-merge again.
dependabot-automerge.ymlcompared the authorgh pr list --json authorreports againstdependabot[bot], and gh 2.98 on the runner renders a bot asapp/dependabot, so from 2026-08-17 every Dependabot PR was refused by a green run. The gate now reads the author from the REST API (user.loginanduser.type, the API's contract rather than the CLI's rendering), and a refusal that is never routine on adependabot/branch — not Dependabot's, or from a fork — exits 1.check_dependabot_gateinscripts/check_docs.pypins both, sabotaged four ways. -
The container gates carry a moved file.
stress-poolandbench-linux-conclusionstar the tree into them0lincontainer, and a tar carries no deletion, so the hold module's move into the fork left a copy behind that failed the source stamp on every container gate of this release. The two/srccopies now clear their source directories first andlinux_sync.shmirrors removals into/work; andcoreis in the sync's build list, because m0-core changed for the first time and the stamp, which hashes sources, matched over an artifact built weeks earlier.
1.1.0 — 2026-09-11
A remote crash fix, and an application layer for programs written in Mojo.
The crash is why this release is not held for the rest of section N: a request carrying a byte that is not UTF-8 killed the server process, from any client, with no handler involved. Five slices on the serving path had it; the query parser and the cookie jar have had it since they were written, so every release including 1.0.0 shipped it.
The layer is ten capabilities (SPEC section N), each gated on the wire, each built app-first — an application written deliberately ugly, a smoke that asserts its bytes, then every piece lifted out from under that green gate. An application written in Mojo now has a URL table whose views the compiler holds to reading or writing their state, a fragment renderer that emits htmx or Datastar attributes from one source, a page-or-fragment decision the framework makes from the request's own headers, reverse routing that fails to compile on a misspelled route, and a form parser. What it does not have is a real application on it; that is the next round.
Nothing in the served contract changed — no flag, no environment variable, no header — so this is an upgrade by version alone.
Added
-
One renderer, two transports (SPEC N7–N9).
Fragmenttakes its frontend vocabulary as a type parameter:Fragment[Htmx]emitshx-post/hx-target/hx-swap,Fragment[Datastar]emitsdata-on:EVENT="@post('…')"with the event chosen by the element (a form submits with{contentType: 'form'}, a field changes, a button or anchor clicks with__prevent) and no target, because Datastar morphs atext/htmlanswer into the element whose id it carries — the id the fragment owns. An app names its vocabulary once (comptime Frag = Fragment[Htmx]) and never writes an attribute of either. A Datastar URL sits inside a JavaScript string literal that HTML escaping cannot protect, so a URL carrying',\, CR or LF is refused (url_forencodes them), and both vocabularies refuse a verb outside the five.apps/datastar_todo's list is now that renderer, one line, verbatim as theelementsof every broadcast frame (test_fragment_frame.mojo) and byte-identical on a fresh page load (smoke-todo); its routes are values reversed withurl_for.page_or_fragmentreads four headers:Datastar-Request: truegets the bare fragment, andHX-History-Restore-Request: trueorHX-Boosted: truebesideHX-Request: truegets the whole document, since htmx swaps a history restore's body into the page it is rebuilding and a boosted navigation takes a document's body;Varynames all four on every answer. A table knows where it is mounted:Views[S](Mount("/native"))registers under the prefix,Mount.url_forreverses to it, andPoolContext.prefixcarries the lane's own prefix to aPoolHandler, som0serve'sMojoMount— now aViewstable — renders links that are followed under--mount /native=mojobysmoke-mojo-mount, the waysmoke-hybridfollows Django'sreverse(). An expression tier over the builder —el,void,attr,flag,text, andFragment.elfor a swapping element — makes an element a string so a renderer nests like its markup with each escaping context named;fragment_notes's list is written in it, its detail view in the builder, both pinned byte-identical (SPEC N10); a child left in the attrs slot is refused rather than rendered into the tag name. The research behind the Datastar arm is one-renderer-two-transports. -
A framework layer for applications written in Mojo (SPEC section N), built app-first.
apps/fragment_notesserves the notes resource as an htmx app; it was written deliberately ugly, gated on wire output alone (smoke-fragment-notes, sabotaged six ways), and then refactored onto each piece under that green gate, in this order:Views[S]— a view is a function the URL table names,add_readhands the state borrowed andadd_writemut(compile-checked bysabotage-views), 405 withAllow, no fallthrough;FragmentandHtmlin m0-core — a fragment writes its root id once andswapgenerates the attribute targeting it from that id,attrowns the delimiters and escapes;page_or_fragment— the framework decides fromHX-Requestwhether to wrap,Varyon both, the shell athinfunction over a context because a trait cannot cross the.mojocboundary;url_for— the route pattern is acomptimeconstant given to the table and reversed with arity checked and values encoded;form(req)— an ordered multimap, empty unless the content type is the form's.apps/notes_apiruns onViewstoo, wire-identical undersmoke-notes;apps/views_patternfrom draft PR #273 was not landed. The design and the refusals are docs/notes/a-fragment-that-names-itself.md.
Changed
-
Datastar pinned at v1.0.3 (was v1.0.2):
m0-datastar'sVERSION, both demos' CDN bundles and the conformance cases' source. A patch release with nothing insdk/changed and every attribute rule the tree records unchanged; the todo demo was driven in Chromium against the new bundle for the three recorded traps (data-initopens the stream, colon-keyed attributes,retry: 'always'reconnects across a restart) before and after the renderer moved ontoFragment. -
The fragment layer hardened after review (SPEC N2–N6 unchanged, the gates widened).
Viewskeeps its routers private (a route registered past the tables was a call through whatever the out-of-bounds index held) and exposesallow_header, which merges both tables; a loop route in the wrong method is 405 with thatAllowrather than 404,OPTIONSon any registered path is 204 with it (a preflight used to get a 405 whoseAllownamed OPTIONS), and a loop route that reachesdispatchis answered there.url_forrefuses an empty value, which reversed/notes/:idto the collection.reply.varykeeps*alone and treats an empty field as absent.HtmlandFragmentmove from m0-core to m0-http beside their consumer — the four-function sentence was an inventory, not the constraint, and a frontend library's attribute names do not belong in the packagebuild-fficompiles intolibm0core;finishconsumes the builder and the constructor refuses an id#idcannot select.page_or_fragmenttakes a status, so a styled 404 is a 404.form(req)isOptional, None unless the media type, compared whole, is the form's.poe check-mojoc-traitis the probe behind the thin-function shell, with a control. The fragment smoke follows a rendered link rather than typing the path, and sends a form body carrying a byte that is not UTF-8. -
The real-application soak was re-run against 1.0.0 (docs/REAL_APP_VALIDATION.md). All four applications, six rows, 215,214 requests compared byte for byte against gunicorn, uvicorn and daphne captures, zero failures, every slot returned, and six SIGTERM drains all inside 0.26 s. The release bumped the version and left the record naming 0.19.0, so
poe milestoneshad been printing the soak STALE since the merge; it reads MET again. One commit had touched the request path in between — the drain split that lets the loop inversion step it — and the churn rows are what put load through it. -
The soak's binary fixtures are generated from a seed (
scripts/soak_fixtures.py). color-separation's two noise PNGs were regenerated unseeded every pass, so the manifest'sbytespins had to be re-measured by hand each time; the same seed now reproduces both files byte for byte. -
The bare-figure rule reads a list of pages, and the roadmap is on it (
FIGURE_PAGESinscripts/check_docs.py). The roadmap's figures drifted precisely because the rule was pointed at one page, so the fix for the drift and the fix for its cause are the same edit. The selftest proves coverage rather than adjacency: it drives the real pages through the real wiring with one doctored, pins the list's membership so a page cannot be dropped quietly, and reverts the rule the way the other doc rules are sabotaged — with a page removed, the same drift goes unnoticed. README.md,docs/SPEC.mdanddocs/RUNNING.mdare still outside it; the selftest prints their bare-figure counts so the backlog cannot rot into a claim.
Fixed
-
A request carrying a byte that is not UTF-8 crashed the process. Five sites on the serving path sliced a request-derived
Stringwith[byte=a:b], which asserts a codepoint boundary and traps:unquote(so oneGET /?x=<0x80>%41killed the loop thread before any handler ran, every app and the production WSGI deployment alike), the cookie jar (built for every request, soCookie: a=<0x80>did the same), the static mount's path handling, theAcceptnegotiator and the ETag matcher. Bytes above 0x7F pass the header parser as obs-text, so all of them were one request away. Every slice is now a byte-span slice, which has none;unquoteis a single byte walk and requires exactly two hex digits for an escape (SPEC G14, one test per site, plus both smokes on the wire). Found by a review probe of the form parser on a pending branch; the query path and the cookie jar had it on every release. -
reply.vary_acceptoverwroteVary. A response that varied on two request headers kept whichever was set last, unnoticed while nothing setVarytwice.reply.varyappends without repeating a name;vary_acceptis built on it. -
apps/notes_apideleted on route drift. Its handler-id chain ended in a barereturn self._delete(...), so a route registered without its own arm did not 404, it deleted. The chain is gone with the move toViews, whosedispatchhas no fallthrough to reach;test_views.mojois the gate. (A wire assertion was first described as guarding this; it cannot, since the old chain answered 405 before the chain for any unregistered method, and the claim is withdrawn.) -
The Mojo pool's distribution test asserted the pool's PRE-elastic wake contract, and failed pull requests that could not reach
MojoPoolat all — most recently on a macOS runner withdistinct=1at 783 ms. The test submits eight 60 ms jobs to three threads and requires more than one to answer, which is what a served burst does; but it drives a bare pool with no event loop, and since the elastic rules landed the wake that spreads such a burst is the LOOP's, notsubmit's.submitwakes nobody while a thread of the lane is busy or spinning, precisely so a burst of trivial jobs does not put N threads on the GIL; a thread that is not coming back soon iswake_aged's case, once per pass. With no loop there was no wake at all, so a spinner descheduled across the burst took all eight jobs at 60 ms apiece. The test now makes the loop's own bottom-of-pass call, which pairs it with the production shape rather than withM0_POOL_ELASTIC=0, the arm nobody runs. Reproduced deterministically by stalling the spinner across the burst: 10 of 10 rounds failed before, 0 of 20 after, and under CPU load the fixed test's median run fell from 295 ms to 228 ms because the siblings no longer sit parked. -
A milestone sabotage stopped applying at 1.0.0. The test that deletes the soak record's version and insists the checker notices searched for the literal
m0serve 0., which the 1.0.0 headline removed from the file, leaving the mutation a no-op.sabotage()reports a no-op as NOT APPLICABLE and fails, so it failed safe; it now matches the shape of a version rather than its digits. -
Three pages still told readers the API would break at 1.0. The release replaced the docs site's caveat and missed the README's two and
docs/RELEASING.md's, which cited the README for a statement no longer there. All four now say what is actually stable:m0serve's flags and environment variables, theM0-Hold/M0-Channelheaders, andm0pub.publish(). The roadmap and the docs index no longer describe 1.0 as ahead of the tree. -
The roadmap argued from figures that had drifted by roughly half. Its "Not planned, and why" section frames every refusal with the rps numbers, and they read 116k on the hello row against a measured 192.3k, and 61k on the executor against 80.8k. The arguments they support are unchanged; the numbers are spans now, rendered from the same artifacts the benchmark page uses. It also cited
lightbug_http/parsing.mojo, which lives inhttp/.
1.0.0 — 2026-09-09
Every capability in docs/SPEC.md names a gate, every
planned row is built or refused with a reason, the real-application soak
is current against this version, and each known issue declares what would
retire it. uv run poe milestones computes those four from the sheet and
the roadmap rather than from anyone's memory, and CI prints it on every
pull request.
The number changes what a minor release may break, and nothing else. The
five known issues are unchanged by it and
are all upstream or packaging matters — Mojo 1.0.0 is still the only stable
toolchain release, so the PythonObject reference leak and the
free-threaded executor refusal both wait on it.
Added
- A view parallelises across
--blocking-threadsonly if it releases the GIL, and a gate says so (SPEC E19).--blocking-threads Ngives a view N handler threads; whether N of them run at once is decided inside the view. C5 covered the isolation half of that pool and nothing covered the concurrency half, which is how an embedding server came to serve the same rate on one handler thread and on two, and a third less on eight: Core ML'spredictholds the lock for its duration.bareapp's/workis one hashlib call over two buffer sizes either side ofHASHLIB_GIL_MINSIZE, so the lock is the only variable between its modes, andsmoke-pool-parallelismcompares the two concurrency ratios on one server in one run. The measurement, including what MAX does differently and why it is not a reason to switch backends on a Mac, is docs/notes/gil-and-the-handler-pool.md. --doctorreports the CPU budget the process can actually use.usable_cpusreads the affinity mask and the cgroup quota rather than the machine's core count, so a container with a fraction of a host's CPUs sizes its zero-config pool from what it was given.--doctorreports which loop an ASGI deployment resolved to, astopology.loop:pump(the default, two threads),inverted, orn/awhere no executor serves the application. It reportedmode: singlefor both shapes before, so the startup banner was the only way to tell them apart.- FastAPI is gated through the ASGI bridge (SPEC L19): routing, a
StreamingResponseand a WebSocket the application closes itself, with the log assertion as the load-bearing half. - A page that restates a capability row's status is checked against the
sheet.
check-docsfails when prose gives a row a status docs/SPEC.md disagrees with, or names a row that does not exist. The validation record had called a row planned for three days after it was built and gated, which is the most misleading kind of drift because that page reads as a live status board. Narrow on purpose: it fires only where a status is presented as a token beside a row id, never on a status word that merely shares a sentence. poe bench-linux-conclusions, and--remote. The benchmark page's conclusions re-run on Linux, in the local container or over ssh on a rented box, with an empty comparison failing rather than passing.
Changed
- The inverted loop's drain is stepped. Under
M0_INVERTED=1a request sitting in anawaitwhen SIGTERM lands is answered when it finishes, rather than being cut off by a drain that could not step the asyncio loop. - The loop inversion is documented, and deliberately not a default. Its
earlier "nothing at saturation" verdict had expired: re-measured, it is
ahead per core at high concurrency and well ahead of uvicorn with uvloop
on a single CPU, and behind the default from two cores up.
--doctorand the banner both name which loop is running (the record). - Two of the benchmark page's four conclusions invert on Linux, and the
artifact system could not have told you, because every rendered artifact
had been recorded on macOS.
poe bench-linux-conclusionsis the check, and the page says which conclusions are platform-specific. - The WSGI and ASGI modes page, and Running, say when handler threads
help. The modes page called
--workers"the answer that works under the GIL", which reads as though threads never give parallelism; it now states the rule E19 gates, and the--blocking-threadsrow in Running says what decides the number. - BENCHMARKS.md's intro and caveats are shorter, and Reproducing names the script behind every table.
- BENCHMARKS.md's prose may not carry a bare figure. Every number in
the page's sentences is either a span the renderer recomputes from the
newest artifact or sits in a paragraph whose ``
marker says where it was observed, and
check-docsrefuses one that is neither (check_bench_prose_figures, sabotaged in its selftest). Four hand-typed figures had outlived their artifacts: the inline row's ratio contradicted the span beside it, the executor's p50 pair quoted the previous run, the isolation effect understated its own table by half, and the per-thread loop costs predated the ring handoff. The isolation claim's figures (the unpooled climb, the hold, the pooled p99 and the factor between them) and the one-thread rows' gap are spans now, computed from the same grouping the table renders; the histories are marked with the note or artifact that records them.
Fixed
- A
poeshell task's"$@"is empty, sobench-linux-conclusions --remotesilently dropped its flag and reported success from the local machine rather than the rented one. - The Linux container's sync could rebuild nothing, or rebuild the wrong tree, and report success either way.
0.19.0 — 2026-09-07
Added
--max-keepalive-requests N/M0_MAX_KEEPALIVE_REQUESTS. The keep-alive request cap is configurable (0 = never close for count) and reported by--doctor; SPEC A3 names it, and the cap smoke pins 100 explicitly so the gate is independent of the default.- Pool instruments for the tail question. Under
M0_POOL_DEBUG=1each pool thread prints histograms of its ring wait, GIL wait and service at shutdown, and the loop prints how many of its waits returned late past their cap and how many passes ran over 1/2/4/8 ms;M0_POOL_SPIN_USsets the idle spin for measurement. Together with the access log they split a request's time into what the server does and what happens between the server and the client, which is how the cap was found (docs/notes/pool-tail.md). - The pool's Linux reproducers are in the tree, and pre-release.
scripts/probes/holds the instruments the engineering record quotes (herd.c,handoff_pingpong.c,bench_threads.py,bench_arms.py,bench_slow.py,xctrace_report.py) and the two Linux reproducers (phase5_probe.py,hold_race_probe.py), which lived untracked under a session directory until now.poe stress-pool(SPEC E18) runs the reproducers in them0lincontainer — twenty rounds ofsmoke-django-realtimephase 5 with a fresh server each, then forty holds under a busy loop — once per shipped wake mode, and is a pre-release step besidestress-asgi(docs/RELEASING.md); the phase-5 probe now exits non-zero on a failed round.poe probe-herdruns the wake-herd ping-pong. The container recipe (linux_setup.sh,linux_sync.sh) moved there too. - Prose may not cite an untracked path.
check-docsfails when a page underdocs/, the README, CLAUDE.md or this changelog cites a.claude/path git does not track — the record cannot cite a file nobody else can open. Its selftest proves the rule fires. The 2026-09-04 soak driver outputs the real-app record cited there are nowbench/soak/2026-09-04/. - A rendered benchmark table may not be stale. Bench artifacts record
the version they measured (
environment.version), andrender_bench_docs.py --check, insidecheck-docs, refuses the newest artifact of any rendered kind that lacks the stamp, was recorded on a dirty tree, or is more than one minor version behindpyproject.toml; its selftest proves each rule can fail. Found by asking whether the published numbers were current: the slow-view isolation table was from 2026-08-26, before the detached loop and the pool hand-off changed its rows, and two headline artifacts were stamped "dirty tree". - The benches refuse to lie about their own variance. Both shell
benches refuse to start, and to begin a round, while any process outside
their own tree is above half a core across three samples
(
scripts/bench_guard.py, whose selftest runs insidecheck-docs); the mixed-workload artifact records the round and the p99's min and max, its table renders the spread beside each median andrender_bench_docs --checkrefuses one with fewer than three rounds; and the check refuses any rendered artifact whose rows moved more than 5 % against the comparator rows' own move since the previous one — the contamination signature — unless--accept-driftstamped it with the comparison it accepts. Three recordings on 2026-09-06 were wrong in ways the scripts could not see (docs/notes/elastic-pool.md).
Changed
- The keep-alive request cap defaults to 1000 (nginx's), from 100.
Every close is a reconnect for the client, and one reconnect per
hundred requests IS the client's 99th percentile: the whole of the
fast-route tail BENCHMARKS.md conceded to Granian. Measured at equal
shape on 3.14t (one worker, four threads,
wrk -c16, Django, slow views beside): cap 100 p99 0.6–3.8 ms; cap 0 or 1000 0.45–0.64 ms; Granian, which has no cap, 0.51–0.55. Four workers: 1.5–4.0 ms to a flat 1.3, the maxima 25 ms to 5. The server's own header-to-send p99 was 0.45–1.35 ms throughout; the loop was never late; GC, the GIL hand-off barrier, QoS placement and the idle spin moved nothing (docs/notes/pool-tail.md). - The zero-config pool serves a trivial view at the one-thread rate.
m0serve app.wsgiruns--blocking-threads min(cores, 8), and those eight threads served a bare WSGI view at 0.67x the one-thread shape on 1.6x the cores: a burst of jobs was taken by every thread awake, they serialized on the GIL with an OS wake per hand-off, and each wake woke every parked thread on macOS (one datagram into eight blocked receivers costs 59 µs of CPU against 3) or the coldest one on Linux. The pool now behaves as one thread until a job has waited: one idle spinner per lane, no wake while a thread is busy or spinning, a wake by name on each thread's own channel to the thread that parked last, and the loop waking a parked sibling for a ring that holds a job and has not been drained for 200 µs — which is what keeps a fast request from waiting out a slow view, and replaces the chained wake; a ring whose pop count moves is a queue one thread is draining, and is left alone however deep on a GIL build, while without a GIL the pool wakes a parked thread whenever there is one and counts the wait from the push, a parked thread beside a queued job being an idle core there. Same session, arms alternated, 16 connections: 172.5k / 179.2k rps on the zero-config shape against 176.9k / 179.4k on one thread, from 118.4k / 122.6k before, on the one-thread shape's cores (docs/notes/elastic-pool.md).M0_POOL_ELASTIC=0is the A/B knob. - The event-loop thread's header path costs what tokio's does. A
known-name index and a presence word on
Headersmake every lookup the loop performs per request O(1) and the parser's duplicate check one AND per field; inserts copy bymemcpyand lowercase eight bytes at a time; the token scanner verifies sixteen bytes per vector; the header read lands in the connection's buffer with no staging copy; the encoder writes a header line as one reservation; the completion drain no longer allocates. Same session, arms alternated, bare WSGI at one worker and one handler thread: 186k rps against 170k at 16 connections and 205k against 186k at 256, the loop thread's cost per request from 5.8 µs to 5.1 and 5.3 to 4.7 — the tokio thread's figures — and the row 0.98x Granian at 16 connections, 0.99x at 256. The isolated parse went from 0.89 µs to 0.62 and the whole user-space request from 1.98 to 1.33 (docs/notes/loop-user-space.md, which also records two levers measured and not kept: a spin before the loop parks, and servicing completions between reads). - The
--blocking-threadshandoff rides in-memory rings, and the two socketpairs carry only wakes and payloads. A bounded MPMC ring per pool lane for jobs and one for completions replace the four datagram syscalls a request used to cost; a pool thread spins before it parks, and a wake is sent only to a side that has announced it is parked (lightbug_http/ring.mojo; the protocol inoffload.mojo's docstring). Same binary, same session, bare WSGI at one worker and one handler thread: 155k rps against 131k at 16 connections and 184k against 154k at 256, the loop thread's per-request cost from 7.5 µs to 6.3 and 5.3. Found by measuring per thread rather than per core (docs/notes/loop-thread-bound.md): the loop was the saturated stage and 1.2 µs of its 7.2 was the handoff.M0_POOL_RING=0restores the datagram handoff for an A/B. - The layer split measures one worker in three shapes, and the
head-to-head with Granian is same-shape. An explicit
--workers 1switches the zero-config pool off, so the old one-worker row was the loop alone against Granian's one blocking thread: it understated m0serve's throughput by a third and flattered its per-core figure. The table now has the inline row (the bridge measurement), the one-handler- thread row (Granian's shape) and zero-config (whatm0serve app.wsgiruns); the four-worker rows are one handler thread per worker on both servers. All four tables re-recorded on 0.18.0; the landing page, README and llms.txt state throughput as the comparison it is rather than as a limit, with the numbers as spans the renderer keeps current.
0.18.0 — 2026-09-05
Added
- Under
--workers N, the worker that wins an accept gives the connection to the least-loaded sibling (SPEC E16;smoke-accept-spreadon both CI legs;lightbug_http/accept_share.mojooverc/fdpass.mojo; docs/notes/accept-sharing.md). Every worker waits on the one listener and the first to wake drained the whole backlog, the same one nearly every time: 32 of 32 connections on macOS, 23 to 31 of 32 on Linux, so a keep-alive load ran at one worker's throughput.SO_REUSEPORTis not portable (Linux hashes, macOS sends everything to the last-bound socket) andEPOLLEXCLUSIVEmade it worse, so the acceptor reads each sibling'sactive + pendingoff the pre-fork shared page and passes the socket over anAF_UNIXchannel withSCM_RIGHTS; the receiver admits it by the accept path's own tail. A hand-off that fails for any reason keeps the connection where it is. After: 16/16 on every platform and mode for bursts and ramps alike, one worker pays nothing, two workers tie on keep-alive throughput, and the Core ML embedding app on two spawned workers goes from 1779 to 3090 req/s (uvicorn's two workers: 2510) with a p99 of 2.8 ms against 5.1.M0_ACCEPT_SHARE=0is the A/B knob. - The executor shim is a Python file.
packages/m0-wsgi/shim/m0_shim.pyis the source of truth for the ~1,500-line programPyBridgeexecs — WSGI'sstart_response, protocol detection, the asyncio executor, the streaming seam's credit and ownership rules, the pub/sub object — andscripts/render_shim.py(poe render-shim) renders it into the Mojo constantbridge.mojoembeds (src/shim_source.mojo, generated and committed, so the binary is still self-contained).check-docsfails when the rendering is stale and proves the literal decodes back to the file byte for byte, which is what letspoe test-shimread the file and still be testing the binary's program; the renderer's--selftestproves the check can fail, and a pyflakes step joins theDocsworkflow (poe lint-shimlocally) — its first run found the one unused import the audit had found by hand. The question of whether Python is the right home for the shim was answered first, with measurements: docs/notes/shim-language.md. The program the binary execs is unchanged except for that import and black's formatting. --spawn-workers/M0_SPAWN_WORKERS(SPEC E15;smoke-spawn-workers). Each worker is forked and at once execs the binary afresh, inheriting the listener, the bus channels and a file-backed shared page by descriptor, so an application may use what a forked child cannot: Core ML, Objective-C, CoreFoundation, libdispatch —urllib.request.getproxies()on macOS kills a forked worker 3 of 3 and answers under spawn. The supervisor, respawn (through a fresh exec) and signal propagation are unchanged; an image that cannot exec exits 78 and ends supervision rather than spending the respawn budget.--doctorreportstopology.worker_mode. The default stays fork: start-to-ready with two workers is 82 ms forked and 92 ms spawned, and throughput ties once started (195k against 199k req/s on hello, alternated). On its own it did not buy the second worker's throughput on macOS, because the same worker won nearly every accept and the Core ML app served 1675 req/s on two spawned workers as on one; accept sharing (E16, above) is what did.
Changed
-
The documentation site is shorter. The sidebar lists eight pages and a link to the map, with nouns for labels; the top bar is Docs, GitHub and PyPI. The quickstart ends at the two-tab demo, and its two-worker, Flask and gunicorn steps are
docs/QUICKSTART_NEXT.md, run by the same smoke in the same scratch directory (run_quickstart.pytakes several--docpages).SPEC.mdandROADMAP.mdopen with the reference rather than a preamble: the row audit moved to the traceability note, and the PythonObject-leak and accept-placement investigations became design notes of their own.llms-full.txtdrops from eight pages to six; the roadmap and the conformance page are underMoreinllms.txt. -
OwningListretired. The fork's private copy ofList(lightbug_http/utils/owning_list.mojo) existed for elements that are Movable but not Copyable — per-slot response buffers, WebSocket parsers, connection provisions, parked requests and responses, the mountedWSGIApps, the client's idle connection. Mojo 1.0'sListtakes those as it is, so every site is a plainListand the file is gone. Measured at parity: the hello row 171.6k/172.7k/174.8k req/s at c16/c64/c256 before and 172.8k/173.7k/176.4k after, the user-space request inbench_http_parts2.11 → 2.05 µs. Probed on the way: the reasonExecutorStatestays behind an address rather than inside the Python-boundExecutorPortis not the container but the element —PythonModuleBuilder's derivedWritablerecurses intoHTTPResponse, whose cookie jar holds aDictthat is notWritable; the docstring now says so. -
The event loop holds no thread state while it serves (
_serve_offloaded,DetachingBackend.set_loop_detached, SPEC E11; docs/notes/detached-loop.md). Under--blocking-threadsand under the ASGI executor the loop used to re-acquire the GIL after every wait and parse, encode and send while holding it — measured blocked in that acquire 36–45 % of wall time under load, so its work never overlapped the Python threads'. Detached, one binary and one env var, arms alternated: the ASGI executor 67.5k → 101–109k rps at 16 connections and 84k → 158k at 256 (1.8 cores where it had one); bare WSGI with one pool thread 71k → 140k. The one place the loop runs Python, the inline fallback, attaches for itself (WSGIHandler.func).M0_LOOP_ATTACHED=1restores the old shape for an A/B. -
Pool threads hand the GIL to the thread that waited for it (
_yield_turninblocking_pool.mojo;poe probe-pool-fairness, pre-release; SPEC E11). The attached loop had been an accidental fairness pump: as a GIL waiter on every pass it forced CPython's 5 ms switch between pool threads, and without it a thread that finishes a job re-takes the GIL before the thread it signalled runs — a fast-route max of seconds under a CPU-bound view with four threads (the same tail Granian's WSGI pool shows on a GIL build). Two atomic counters: a thread that has held its run for a millisecond and drops the GIL while another is parked on it yields until that one has attached. Nothing is held across a job, so slow-view isolation is untouched, and no waiter's extra wait exceeds the millisecond.apps/wsgi_baregains/busy, the CPU-bound view the probe drives. -
Repo hygiene from the 2026-09-05 audit.
.gitignorecovers.claude/worktrees/(a 652 MB checkout of this repo was sitting untracked) and.claude/handoffs/; the deploy workflow pinssetup-flyctlto the 1.5 release commit instead of@master; nine pyflakes items (unused imports, a placeholder-free f-string) cleared.
Removed
StringSlice→StringSpan, 98 sites acrosspackages/,apps/andscripts/: the 1.0 name, from the prelude, ahead of the alias starting to warn on a later pin. A pure identifier substitution; the ratchet stays at 0.- Two uninstantiated bodies in the fork, and the UDP send path one
anchored:
memmoveinio/bytes.mojo, andSocket.send_towithUDPConnection.write_to, thesendtobinding and its twenty-one error structs plus theSendtoErrorvariant (NOTICE has the list). Each held a deprecated spelling the compiler never flagged because nothing elaborated the body; a bareundefined_namein each survived build-all, test-all, build-apps and build-serve, which is the proof. 743 lines gone.
Fixed
- A header line cut between its CR and its LF was answered 400.
scan_to_eoltreated a carriage return as the buffer's last byte as a malformed line rather than a line whose LF had not arrived, so a request whose TCP segment boundary fell between the two bytes of a header's CRLF was rejected instead of waited for; the request line and the terminating empty line already answered incomplete there. Found by the release fuzz sweep (fuzz-request-long, seed 5, iteration 147067) under its invalid-is-sticky rule, reproduced on v0.17.1, so latent in every release before this one.test_parsing.mojopins the shape. - Five
Stringbuilders read a local buffer after Mojo had freed it.Span(unsafe_ptr=out.unsafe_ptr(), length=...)carries no origin, so under the manual's after-every-sub-expression destruction theListbehind it is gone beforeString(unsafe_from_utf8=...)copies — a probe on the pinned compiler printed the buffer's free before the copy. The output was right only because the freed block still held its bytes.Span(out)at_format_hex,escape_json_string,parse_json_field, the access-log line and the Datastar SSE builder;compute_etagtakes the same spelling. - The warning ratchet's baseline is 0, from 68. All three "unfixable on
Mojo 1.0.0" claims failed to reproduce when probed against the pinned
compiler:
abi("C")is a function effect placed before the return arrow (the four@exports inffi_exports.mojo),unsafe_allocis importable fromstd.memory.alloc(the twelve fork allocation sites), and a doc-string summary may open with a backticked identifier (the 52 test andmultiworker.mojosummaries). CLAUDE.md's Mojo 1.0 patterns 8 and 9 are corrected in the same pass:ListandOptionaltake Movable-only elements on this toolchain, soOwningListis a retirement candidate.
0.17.1 — 2026-09-03
Added
-
Citation tracking (
scripts/check_citations.py,poe check-citations,.github/workflows/citations.yml, SPEC F15 and F16). EveryRFC nnnnin a tracked text file is looked up inscripts/rfc_status.json, a committed snapshot of the RFC Editor's per-document JSON: a citation of an obsoleted RFC fails every pull request unless its paragraph cites a successor, and a monthly job re-fetches the snapshot and files an issue when a document moves. First run it found the parser, the chunked decoder, the date formatter, content negotiation and their tests citing RFC 7230 and RFC 7231 (replaced by RFC 9110 and RFC 9112 in 2022) and RFC 5987 (replaced by RFC 8187); each is re-pointed to the successor's section. The rules are pure functions of text, selftested and sabotaged the way the spec sheet's are. -
The live two-tab demo beside the docs (
apps/demo,deploy/demo,poe smoke-demo, SPEC M17; https://demo.m0serve.dev once the Fly app exists). One file of sync Django in the quickstart's shape, served bym0serve --realtime --workers 2from its own Fly app on a subdomain -- never a mount inside the docs app, so untrusted realtime traffic shares no process with the site -- and on ONE machine, because the publish bus is per process. The page holds an SSE stream and a WebSocket side by side, says which m0serve version serves it and which worker published each line, and carries what a public page needs that the tutorial does not: channels namespaced per visitor by a random cookie token (a stranger's tab hears nothing), 280 bytes a message (413), 30 a minute per visitor per worker (429 withRetry-After), a foreignOriginrefused on the upgrade, binary frames dropped, nothing stored.scripts/demo_probe.pyproves every one of those from outside -- against the image built from the tree's wheel in thepid1job, with m0serve as PID 1 anddocker stopdraining with a stream held, and against the live URL in the deploy workflow's newdeploy-demojob (secretFLY_DEPLOY_DEMO). Both verify steps wait for the health path to answer steadily before asserting anything, and the demo's retries the probe up to three times 20 s apart:flyctl deployreturns before the edge is stable, and the first deploy's verify hit a TLS EOF from the proxy while Fly was creating the second machine and bouncing the first. Four sabotages of the application -- shared channel, no rate limit, no Origin check, no size cap -- each failed the probe in the phase that names the guard. -
The headline claim, gated clause by clause (SPEC I20, K11, M16;
QUICKSTART.md§7–8,poe smoke-flask-realtime). The sentence names Flask, and nothing held a stream or a socket from a Flask view: K10 proves plain WSGI. The quickstart now carries the same four views in Flask, served with--workers 2and checked with curl, andsmoke-flask-realtimeextracts that file from the page itself and drives it with the Django rows' RFC 6455 probe, one stream and one socket pinned per worker. Gating it found the Flask-specific line the claim was missing: Werkzeug's router answers 400 to an upgrade request on an ordinary rule before any view runs, so the socket route is declaredwebsocket=True. "No second process" and "no dependencies" are asserted rather than stated (exactly a supervisor and two m0serve workers bypgrep -x, an emptyRequires:frompip show), and the README's "degrades under gunicorn" sentence is executed: the Django file served by gunicorn answers the hold views as short plain responses inside curl's deadline, the upgrade as 200, andpublish()reports 0 workers without raising. The published wheel was run through the whole page from a scratch directory on macOS and in apython:3.12-slimcontainer, which is the fresh-project check the sentence was owed.
Changed
docs/WSGI_VS_ASGI.mdis the concise answer to why there are two execution modes, in a page: what each mode is, why one would not do, what WSGI gets from the handler pool and held connections, what ASGI gets from the executor, what free-threading changes, the cliffs, and how to choose. The dated essay it replaced, whose opening still made "the case for not building an ASGI host", is kept as written atdocs/notes/wsgi-vs-asgi-history.mdwith its section numbers, and every citation of §5, §8 and §9 (CLAUDE.md, the README, the conformance and performance pages, the notes) now points there.- The documentation, restructured for a first-time reader and for an
agent (
scripts/docsite.py,docs/ROADMAP.md,docs/notes/,docs/RUNNING.md,docs/README.md,llms.txt,apps/site/home.md). The site now opens on a short home page rather than the repository's README, and its pages are grouped by intent: start here (quickstart, the new Running m0serve guide covering flags, the execution modes and when each applies, proxies, shutdown and exit codes, the capability matrix, the map), understanding the design, measurements, the project record, and the Mojo framework underneath. ROADMAP.md is a state page now: milestones, known issues, planned, not planned and why, recently resolved, at 380 lines where it was 2,726; its twenty-one long-form narratives (the Django server aims, each shipped subsystem, the gates, the open questions, the post-mortems) are design notes underdocs/notes/, kept as written, rendered under/notes/with their own index and picked up by the site without a page-table entry. Every page opens with a one-sentence lede, the description a search result shows; pages with six or more headings get an "On this page" list. For agents,llms.txtis curated (the operating contract, then the essential pages) with the rest under the spec's## Optionaltier, andllms-full.txtcarries only the essentials, at a fraction of its former size, naming what it omits and where to find it.poe milestones, the spec checker and the link checker read the new shape unchanged.
Fixed
-
The ASGI smoke's two shutdown phases bound their wait for the server to exit, and the
Testsjob caps are raised to what green runs now take. Both phases ended in a barewait $pidafter SIGTERM, so a server that never exited was a smoke that never finished: on 2026-09-02 the inverted-mode run on macOS sat 7 minutes inside that wait until the job's 20-minute cap cancelled it, with a straym0servefor the runner to reap and no log to read -- the step's last line was the WebSocket probe passing. Await_exithelper polls for 30 s (the drain is 5 s and the thread join another 5), then kills the server and fails naming the phase, withasgi.logprinted. The hang itself has not reproduced: the sequence ran 20 of 20 clean under twenty CPU hogs on this tree, in both shutdown phases, and every macOS run since has passed; the two slot-recycle fixes above landed after the commit that hung, and the next occurrence will at least say where it stood. Separately, the smoke job's green runs take 16-19 minutes across some fifty steps against a 20-minute cap set when they took 3-5 -- a 19.5-minute green run is on record -- so the smoke cap is 35 and unit-tests (11-15 minutes) 30. -
A Starlette streaming response no longer leaves a traceback per response in the log (
bridge.mojo, the executor shim). Starlette -- so FastAPI and FastHTML -- produces aStreamingResponsebody inside an anyio task group, and the shim stamped its streaming mark onasyncio.current_task(), the CHILD. The request task's done-callback then took the stream for a buffered result and raisedTypeErrorunpackingNone(the body had already been delivered, so nothing failed but the log), and a client disconnect cancelled the child rather than the request. The mark and the cancellable task are now the slot's OWNER. Found by running FastAPI against the published wheel;test-shimgains the child-task shape plus its sabotage, andsmoke-fasthtmlrefuses the traceback in its log. -
A recycled slot no longer inherits the previous WebSocket's accept. The executor shim's
spawn_wscleared a recycled slot's stale disconnect mark but not its_exec_ws_acceptedmembership, and the previous task's done-callback — correctly, no longer the owner — does not clean the slot up either. A new handshake landing on a slot whose previous connection was an accepted socket therefore looked pre-accepted: an application that returned without answering it sentws_closeinstead ofws_reject, so the held 101 was never released and the client hung against a clean server log; a pre-acceptwebsocket.sendwas silently tolerated instead of raising. Found by auditing the bridge's slot-recycle hygiene;shim_ownership.pygained the WS→WS recycle test (test_a_websocket_recycle_forgets_the_predecessors_accept) and its sabotage, which fails exactly that test on the pre-fix shim.
0.17.0 — 2026-09-02
Added
-
ASGI on a free-threaded CPython build is refused, not crashed (SPEC L18, E10; ROADMAP Known issues). The weekly py-canary found the asyncio executor segfaulting on 3.14t while building its
ExecutorPortPython type: Mojo 1.0's stdlib laysPyObjectout for the GIL build, soPyModule_Createmisreads the module definition (modular/modular#5726).m0servenow probes the build wherever the executor would engage -- prefork's worker, the threaded path,--doctor-- and exits 78 with a sentence naming the issue and the fix (a GIL-enabled interpreter with--workers); an ASGI app under--threadsis therefore refused on this toolchain.WorkerSupervisortreats a worker's exit 78 as the refusal it is: no respawn, and the supervisor exits 78 itself, where before ten respawns and an exit 1 reported a crash.smoke-django-realtimephase 6 asserts the refusal on a free-threaded build (alone, via--doctor, and under--workers 2) and the full mixed server on a GIL build.py-canary.ymlno longer fail-fasts (Linux was cancelled before reaching the failure, twice) and can file its issue (the default token was read-only, so it never had). -
The documentation site's deployment (
deploy/site/,poe deploy-site,poe smoke-site-image; SPEC F14). Apython:3.12-slimimage with the m0serve wheel, the site rendered forhttps://m0serve.devand the fallback application, on one always-on 256 MB Fly.io machine (deploy/site/fly.toml);.github/workflows/deploy-site.ymldeploys after every successfulRelease, pinning that release's wheel, or on demand with a version.scripts/site_image_probe.pybuilds the same Dockerfile from the tree's own wheel in CI and asserts the served shape through a published port, that m0serve is PID 1, and thatdocker stopis the drain. The PyPI project now linksDocumentationto the site. The first public deploy follows the next release: the XML sitemap and the fallback application's redirect and 404 need thexmlcontent type and the static mount's fall-through above, which 0.16.0 does not have; the probe passes everything else on that wheel and fails at exactly that phase. -
The documentation site (
scripts/docsite.py,apps/site,poe build-site/serve-site/smoke-site; SPEC F13). README, QUICKSTART, CHANGELOG, PROVENANCE and every page underdocs/render to HTML and are served by m0serve itself through--static, withllms.txtat the root (the repository's own, its links made absolute, plus an index of every page) andllms-full.txtbeside it for agents, a Markdown twin beside every page advertised by<link rel="alternate" type="text/markdown">,sitemap.xml,robots.txtandspec.jsonat stable URLs, and canonical links, Open Graph and JSON-LD on every page. Titles and descriptions live in one table and are written for the question a reader searched, not the file's name. Every relative link is resolved at build time and a link to nothing fails the build; everydocs/*.mdmust be listed, so a page cannot ship without a title. The link check is standard-library and runs insidecheck-docs, so doc-only pull requests get it, and--selftestproves it can fail. The rewriter is a fence-aware regex; the build then walks the parsed token stream and refuses any relative link it missed, which found one on the first run (a link whose text wraps a line).xmljoined the static mount's content types asapplication/xml, because the sitemap was going out as octet-stream. Deployment is the open half: the build takes--base-url, and nothing serves it publicly yet. -
The scheduling-stickiness sighting reproduced, and its fix direction corrected (
docs/ROADMAP.mdKnown issues,scripts/accept_placement.py). The one CI failure ofsmoke-reload's two-worker phase — eighty sequential connections all answered by one of two live workers — is CPU placement, not load: with the client on one worker's CPU the other worker wins every accept (80 of 80, measured on the 0.16.0 wheel in a Linux container), because the accept-queue wakeup runs on the client's CPU and the co-located worker is last to run.EPOLLEXCLUSIVE, which the entry named as a fix direction, sends 80 of 80 to one worker in every placement; per-workerSO_REUSEPORTlisteners are the only shape that balances. The entry now records the numbers and why the server keeps its shared listener.smoke-reload's two-worker phase no longer asserts scheduler fairness: instead of waiting for both pids to happen to answer, it stops the worker that did (SIGSTOP; the supervisor reaps withWNOHANG, so that is neither a crash nor a respawn) and requires the other to serve the new module 8 of 8, both pids tied to the supervisor's re-fork log. Sabotaged in both layers; passes 4 of 4 on Linux against the 0.16.0 wheel, including with the whole smoke pinned to one CPU. -
The 0.16.0 real-application soak (
docs/REAL_APP_VALIDATION.md, rewritten; the milestone's soak reads current). Four applications —transcripts,color-separation,textshelfand Wagtail'sbakerydemo— driven byscripts/soak.pyagainst captures from gunicorn, uvicorn and daphne: 373,000 responses byte-identical across the clean rows, with logins, 9.7 MB multipart uploads, abandoned holds, four-worker prefork, and SIGTERM churn. One server defect found and left open as SPEC D9: a request body still arriving at SIGTERM holds the drain to its 5 s deadline, because the drain loop reads nothing new;scripts/drain_upload_probe.pyreproduces it bare and is the gate the fix will land with. Everything else that differed was traced, by measurement, to the application: Wagtail rendering from sets under different hash seeds, typst's per-process font tags and PDF dates, textshelf's SSE views stalling any WSGI pool and its unpooled Postgres connections at four workers. Manifests for both apps, multipart uploads, status-only routes and supervisor-aware sampling in the driver. -
The soak driver —
scripts/soak.py, manifests underscripts/soak_manifests/,poe soak-apps(pre-release, three legs) andpoe soak-selftest.docs/REAL_APP_VALIDATION.md's phase 5 was a request loop that sampled RSS; three of the six real-application defects were silent (a clean status over a short or empty body) and a request loop passes every one. This asserts bytes instead: every response is compared — status, normalised headers, body digest — against a capture recorded from a reference server (--baseline, gunicorn or uvicorn), under five concurrent populations (keep-alive bursts that cross the cap on every connection, streams, uploads and logins, WebSocket echoes, and abandoners that vanish mid-body and reuse the freed slot at once), with the server SIGTERM'd or--reloaded underneath (--churn-every), and the server's own/__metricssampled beside RSS, fds and threads. A manifest'sloginblock is a CSRF form round trip with a cookie jar per session, so the authenticated surface of a real application is reachable and the login response itself is verified —Set-Cookienormalised to its attributes, defect 1's exact shape. The comparator is a pure function with a--selftestthat found the driver's own first hole: a substitution greedy enough to absorb a truncation blinds the instrument, so a capture now refuses any route its patterns blind, and carries a fingerprint of the rules it was recorded under. Shaken down onapps/hybrid_mixandapps/asgi_bare(3.5 M responses against a uvicorn capture, zero differences), then on Wagtail's bakerydemo and textshelf, each byte-identical to gunicorn/daphne with logins and churn — every body difference traced, by measurement, to the application rendering from Python sets under different hash seeds. -
SIGTERM as PID 1 in a container is gated, every pull request (SPEC M11, the last beta row).
docker stopis SIGTERM to PID 1 and nothing else, and PID 1 gets no default signal dispositions from the kernel — a SIGTERM arriving with no handler installed is discarded, not fatal. This server installs its handlers post-fork by design, so every in-process SIGTERM gate proved the handler works once installed while proving nothing about the one environment where the default disposition cannot paper over a missing install.poe smoke-pid1(scripts/pid1_probe.py) runs the shipped wheel exec'd as PID 1 inpython:3.12-slim— checked via/proc/1/cmdline's argv[0], not trusted — and stops it in both process shapes: one process alone, and the supervisor reaping two workers whose exits must be clean rather than by the propagated signal. The sabotaged shapes were each measured in a container before the gate counted: the worker install skipped is SIGKILL at the deadline (exit 137 at 10.1s of a 10s grace) alone and workers dying by signal 15 under a supervisor; the supervisor's arm skipped silently is the deadline again with the single shape green; the announced degradation is caught by its own log line. The probe's PID 1 premise check failed its first sabotage —sh -c's cmdline contains "m0serve", so a substring check over the whole cmdline blessed the shell — and checks argv[0] alone for that reason. -
/__metricsrenders a request-latency histogram (SPEC F5). Six log-spacedlebounds — 100µs, 1ms, 10ms, 100ms, 1s, +Inf — as a standard Prometheus histogram (_bucket/_sum/_count), integer-only and O(1) on the event loop thread: one band counter incremented per response, accumulated cumulatively at render time. Sampled in_after_sendfrom the same clock the access log reads, and only when a header stamp exists, so a pushed frame on a streaming slot cannot sample the epoch as a latency. Per-loop like every other metric; the scraper aggregates. The serve smoke's metrics phase now checks coherence throughscripts/histogram_check.py— the documented bounds in order, cumulative counts non-decreasing,le="+Inf"equal to_count, and a_countcovering the phase's own requests, that last being the assertion that fails when recording is never called. The checker's selftest runs in the same phase (six doctored expositions, each flagged by the rule that names it), and three sabotages were caught by name before the gate counted: recording never called (_count is 0 after at least 5 requests), the cumulative render broken (counts decrease: [5, 1, 1, 0, 0, 0]and+Inf is 0 but _count is 7), and anleboundary off by one (twotest_metrics.mojotests). 6 unit tests pin the boundary math. -
Autobahn|Testsuite is wired to the pre-release cadence (SPEC I13).
poe autobahndrives the suite's sections separately (a single pass wedges on the slot a cap-killed connection just released), skips 9/12/13, and compares both directions against the pinned baseline — 240 of 247, every failure being I17's ≥64 KB outbox cap: a failure outside those seven cases is new and fails the run, and one of the seven passing fails it too, the cap having moved out from under the sheet. The image is version-pinned (digest-identical to the 2026-08-30 baseline's), which is what lets the per-section case counts be asserted exactly; the server is the runner's own pure-echo ASGI app, becauseasgi_bare's/wsprefix-echoes text and Autobahn's byte-identity cases would score that as failures. The comparator's--selftest(five doctored result sets, each flagged by the rule that names it) runs before anything is believed. The wired run reproduced the baseline exactly, and the live sabotage — I16's close-code validation reverted — was caught as nine named new failures (7.9.1–7.9.9) on the first section-7 run.docs/RELEASING.mdnow lists it besidestress-asgi, along withfuzz-request-longandsabotage-outbox-cap, which were pre-release tasks the checklist never named. -
Coverage is declared by the gate, not merely cited by the spec sheet (SPEC F12; ROADMAP "Traceability", phase 2). Every one of the 119
verified (every PR)rows now declares its coverage in its own gate: acovers: A7line in the cited test's docstring for the 39 unit-cited rows, and ascripts/emit.py --covers A7call in what the cited step runs for the 80 step-cited ones — the latter also recorded by the real run through$M0_RESULTS, rendered in the CI summary as a tally. Two new checker rules run beside the citation rules: every declared id must name a row that exists, and every gated row's declaration must AGREE with its citation — a row declared only somewhere its evidence does not cite is the exact mis-citation class the 2026-08-30 audit found six of, now a red build instead of an audit finding. Weekly and pre-release rows keep declared-static citations (their runs are absent from PR CI); the citation-shape rules stay, guarding what declarations cannot (real cadences, unconditional steps, the two closed sets). Four new sabotages revert the rules, each caught by the failure that names it — and the migration itself surfaced a masking hazard: appending a recorder call after a smoke body's last command replaces the exit status poe reads, sosmoke-poolandsmoke-ws-inbound, whose final probe's status was the task's status, now carry an explicit|| exit 1there. -
The request decoder is fuzzed, every pull request (SPEC G13).
scripts/fuzz_request.mojomutates a seed corpus of real and hostile requests throughparse_request_headersandHTTPChunkedDecoder.decode— no socket, no server, because the decoder is a pure function over bytes. Deterministic from its seed, so a CI failure names the seed and iteration and the same run reproduces it.Beyond "does not crash" it asserts four properties: parsing is deterministic; an INVALID request cannot become valid by appending bytes (the smuggling-relevant one — "invalid, not incomplete" is what stops an attacker's payload being read as the next request); a request that parses is unchanged by bytes after it, consuming the same count; and the chunked decoder's
ret, decoded length andpending_bytesall index the buffer they were given, since those feed copy sizes in the loop.480,000 mutations across eight seeds found nothing, which is a believable result for a decoder with this unit suite and worth nothing on its own — so two things guard the negative. The run refuses to pass on thin coverage (it counts parsed, rejected, incomplete and both chunked outcomes, and fails if any bucket is empty), and
poe sabotage-fuzzbreaks each invariant in the decoder and requires the fuzzer to report that invariant by name. Without them "no findings" and "checks nothing" are the same output.poe fuzz-request-longis the release sweep (8 seeds x 250k). -
The chunked decoder's trailer states are gated (SPEC A10). The servers build their decoder with
consume_trailer = True— which is what makes a body end where RFC 9112 says it ends — but the round-trip tests set that flag over a wire carrying no trailer section, so every state belowIN_TRAILERS_LINE_HEADwas reached by no test at all.Eight tests in
test_parsing.mojonow cover the section: consumed whole (including its terminating CRLF, whose absence is what makes a close send an RST), several fields, no trailer byte reaching the decoded body, the framing fields RFC 9110 §6.5 says a trailer must not honour (Content-Length,Transfer-Encoding,Host), the pipelined tail surviving byte for byte, and the section bounded by the existing abuse ratio — trailer bytes advancesrcand neverdst, so they are charged as pure overhead and no second limit is needed. The default-setting half is asserted too, so a decoder that swallowed to the end of the buffer cannot pass.poe sabotage-trailersreverts each of the six rules and requires a failure for every one; it runs intest-alland in CI. Nothing was found wrong with the implementation — the gap was in the evidence.
Changed
-
Three renames the next Mojo release forces, applied now because their replacements already compile on 1.0.0:
InlineArray→Array(header.mojo,test_sendfile.mojo),std.ffi._CPointer→OptionalPointer(bridge.mojo's twoPyBytes_*signatures) andmemcpy→unsafe_memcpy(four fork files). Verified by building the tree on1.1.0.dev2026090205in an isolated copy: with these plus the two renames that cannot be applied ahead of time (Atomic[DType.X]→Atomic[X],_CTimeSpec.tv_subsec→tv_nsec),build-all, all 1011 Mojo tests,build-apps,build-serveandsmoke-djangoare green there. ThePythonObjectleak entry indocs/ROADMAP.md(Known issues) now records the upstream issue and fix commit (#6833,c9d5048575, authored nine hours after the 1.0.0 wheel was uploaded), the leak measured per operation on both toolchains, and the verified break list in place of the one read from the release notes, which was three items short and one item stale. -
A static mount's miss falls through to the application.
StaticFiles.serve(and so--static) used to answer every path under its prefix definitively — a missing file was the mount's own JSON 404 and a POST anywhere under it a 405 — which made--static /=dirswallow the application entirely. It now answersNonefor a path that names no regular file under the root, so a root mount can front an application's routes (thetry_files/ whitenoise shape); a missing asset under/static/now gets the application's 404 page rather than the mount's JSON. What still never reaches the application: a traversal, an encoded slash that would open a segment, a malformed segment — those stay the mount's 404 (G5, G6). The 405 for a method other than GET/HEAD is now about a file the mount holds, checked after existence. Found by the documentation site, which needs the redirect for/docs/specand its own 404 page from the application behind the mount. -
poe autobahnprovisions its own docker on a Mac. With no daemon answering it starts a 4 GiB colima VM (enough for the wstest container; the echo server runs on the host) and stops that VM when the run ends, pass or fail. A daemon that was already up — colima started for other work, or native Linux docker — is used as found and never stopped: only what the run started is the run's to reap. Both branches measured: a stopped VM is started at 4 GiB (colima start --memory 4resizes the existing profile down from 8) and reaped after the suite; a running one is left running. The sizing matters on a 16 GB machine, where the forgotten 8 GiB reservation was half the RAM. -
Bench prose numbers are now generated in place, not pattern-matched after the fact.
check_bench_proseheld 24 hand-written regexes against 12 quantities across three documents — every legitimate rewording broke a pattern, only a phrase's first occurrence was checked, and nothing proved the checker could still fail. It is replaced by inlinenum:spans:render_bench_docs.pycomputes each quantity from the newest artifacts and writes the number between markers naming the quantity and its decimals (~1.2x), so the sentence around it stays free to be reworded and--check— already run bypoe check-docs— refuses any stale span. A span naming an unknown quantity, a quantity whose artifact row vanished, or an opener whose closer was deleted is an error, not a skip, and the renderer's new selftest (run at the top ofcheck-docs, so doc-only pull requests prove it too) insists each of those failures fires. All 25 span sites were migrated value-neutrally — every number the prose showed is byte-identical to what the newest artifact computes — and the migration corrected one overstated claim found along the way: WSGI_PERFORMANCE.md said the old checker held the mixed-workload prose to its artifact, but that artifact records throughput medians only and no checker ever recomputed the p99 narrative; the page now says which numbers are held to the file and which are quoted measurements. -
SO_REUSEPORT(SPEC D6) now records the property that is actually gated, and M13's reason is corrected. D6 claimed the option itself and satimplementedfor want of a smoke covering "the zero-downtime handover it would enable" — but no shipped path can enable it:reuse_portis opt-in onListenConfig, defaults off, and has no caller, no flag and no environment variable, because workers and threads all accept from ONE listener bound before the fork. The property that matters and IS gated is the default:smoke-serveproves a second server on a busy port fails to bind loudly rather than silently taking a share of the connections, which is what it did until 0.14.0.M13 (systemd socket activation) was
out of scopebecause "SO_REUSEPORTcovers the restart case it is usually wanted for". That was not true for anyone runningm0serve. The row keeps its status on the honest reason — nobody has asked for it — and now says what a restart does get, which is the supervisor's graceful drain. This is the class of error the checker cannot catch: it validates that a citation resolves, never that a reason is true. -
The blocking
listen_and_serveloop no longer sends aKeep-Aliveheader. It was the only site that did — the event loop, which every shipped binary runs, has never sent one — so the two paths disagreed on the wire for a header that is not in RFC 9110 or 9112 (RFC 2068 §19.7.1 described it; RFC 2616 dropped it) and that browsers ignore.It was also unreachable and wrong. Nothing in this tree calls
listen_and_serve, no test asserted the header, and themaxvalue was off by one:maxcounts ADDITIONAL requests, andkeepalive_countis not incremented until after the response is built, so request 99 of 100 advertisedmax=2and served one more.Aligning the other way would have put a header nobody reads on every keep-alive response of the hot path, plus a spec row and a permanent gate. If a client pool ever needs
timeout=to avoid racing the idle close, the event loop is where to add it, with a row and a gate.listen_and_serveis public API (README), so this is a visible change for a library caller that was reading the header;Connection: keep-aliveis unaffected.
Fixed
-
The nightly canary could not alert, and would have misreported its first success.
nightly-canary.ymlfailed on all three scheduled runs (2026-08-18, 08-25, 09-01) and filed no issue:gh issue create --label nightly-breakagefailed because the label did not exist. It does now. Andtrailer_sabotage.py,fuzz_sabotage.pyandpool_sabotage.pyran the compiler asuv run mojo, which re-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 thesabotage-trailersstep oftest-allwould have swapped a canary back to stable mid-run and the next step's "precompiled file is newer than the compiler" would have read as a nightly break. They run the venv's ownmojonow; on the nightly copy the step passes and the toolchain stays put. -
A request whose body was still arriving at SIGTERM held the drain to its 5 s deadline (SPEC D9). The drain loop dispatched writes only and read nothing new, so a half-received upload was neither completed nor closed: the client was reset at 5.03 s and the process left at 5.09 s — half of
docker stop's patience for a request that completes in milliseconds. The same loop cut any response too large for onesendat its first write readiness. The drain now runs ordinary event-loop passes for its budget, with_close_between_requestsafter each so only bytes already sent are served; gated bysmoke-drain-uploadin both execution shapes, two-sided (answered whole, exited inside 3 s), and sabotaged by restoring the old loop. Found by the soak driver's uploads population on color-separation. -
A chunked body that arrived with its headers was bounded by nothing. Both request-body limits — the decoded cap and the raw ceiling at twice it — lived in the
READING_BODYbranch, but a chunked body whose bytes arrive in the same read as its headers is decoded and dispatched inline at the header site, which never runs that branch. Sending head and body in one write was enough to escape both: 512 one-byte chunks are 3,077 raw bytes against a 2,048 ceiling and answered200, where the same body paced across several writes answered413.So whether a request was bounded came down to how the client's writes happened to be coalesced by the kernel — which is also why this survived:
chunked_overhead_probe.pysends 512 separate 6-byte writes, and they coalesce differently on a loaded CI runner than on a laptop. It surfaced as an unrelated pull request going red on macOS.The probe now sends the over-ceiling body three ways — paced, head and body in one write, and split across two writes at the ceiling — so the shape that reaches each decode site is chosen rather than left to the kernel. Reverting the fix fails the one-write phase and no other, which is how the fix was scoped: a second check added in the
READING_BODYbranch turned out to change no observable behaviour, because the existing pre-decode buffer-size test already refuses those shapes, and it was removed rather than shipped unpinned. -
The keep-alive request cap destroyed the response it fired on, for a streamed body and for a WebSocket upgrade alike. On the hundredth request of a keep-alive connection (
max_keepalive_requests, 100) a streamed ASGI response went out as a200carrying itsContent-Lengthand zero body bytes, and a WebSocket upgrade completed its handshake with101and then never sent a frame. Both were silent — correct status line, nothing logged._finish_responseclearsshould_closefor a stream and for a 101, because neither is keep-alive reuse: each owns the connection until it ends. The cap check below those two branches was guarded bynot should_close, which is exactly the state they had just established, so the two shapes that had opted out were the two it caught;_after_sendthen closed the slot as soon as the head drained, before any body frame arrived over the executor's chunk channel. The blocking loop cannot reach this —gate_streaming_responseturns both shapes into a 409 before the cap is consulted — and it is left unchanged rather than given a condition that can never be false.Found by re-soaking a real Django application against 0.16.0 (
docs/REAL_APP_VALIDATION.md): 9 truncated responses in 6,000 requests, all on one 124 KB static file, at intervals of exactly 700 — every hundredth time that route was hit. Gated bypoe smoke-keepalive-cap(SPEC A3), whose third phase asserts the cap still fires for an ordinary response, so the gate cannot pass on a build whose cap never fires.
0.16.0 — 2026-08-31
Four WebSocket correctness fixes, two of them silent — a slot leak and a message loss that no client could detect. Three were found by gating a mechanism nothing gated, which is the theme of the release rather than a coincidence.
Fixed
- A WebSocket peer that never answers Close no longer holds its slot for
ever. v0.15.1 made the server wait for the peer's Close reply (RFC 6455
§5.5.1) and bounded that wait with the idle sweep. The bound did not work:
none of the linger's four sites is a transition, so the drain re-stamped
the deadline on EVERY pass while a slot lingered — about once a second,
two seconds into the future — and the sweep could never overtake it.
Measured: a peer that received Close and never replied still held its slot
at 40 s. Armed once now, only when the deadline is still zero.
A4is gated (--idle-timeout,scripts/idle_timeout_probe.py,poe smoke-idle-timeout) andL16is a new row for the BOUND, separate from L15 for the ORDER, because L15's 64-way concurrent-close phase passes on both broken servers. - Close frames are validated, not echoed. RFC 6455 §7.4.1 divides close codes into ones a peer may put on the wire and ones it may not; the parser copied the first two payload bytes into its echo unexamined. The contradiction is sharpest at 1006, "abnormal closure", which names the ABSENCE of a close frame — so a close frame carrying it cannot be honest, and the server answered it with its own 1006. Now 1000-1003, 1007-1014 (1012-1014 were registered with IANA after the RFC, hence the range ending at 1014 and excluding 1015) and 3000-4999 are echoed; everything else fails the connection with 1002. A one-byte body is a protocol error (§5.5.1), and a reason that is not valid UTF-8 is 1007 (§8.1). Autobahn section 7 goes 24 OK / 3 informational / 10 FAILED to 34 / 3 / 0.
- Inbound WebSocket messages are no longer dropped when a client stalls.
The outbound direction was credit-gated and the inbound direction had no
backpressure of any kind, so once the executor's submit channel filled,
each further message was discarded with a log line the client can never
see: 2932 of 3000 lost at 4 KB. The two directions were coupled, which
is why the threshold was so low — an app that awaits
sendinside itsreceiveloop stops receiving when its client stops reading, which stops the drain that inbound messages depend on. Inbound now has a window (WS_IN_WINDOW, acked cumulatively as the application consumes), the loop suspends the socket's READ when it cannot forward, and what it has already taken off the wire is parked and delivered late. A parked message is owed, never dropped. - A WebSocket client sending more than one socket read's worth no longer
stalls on Linux. The WebSocket read path took one
recvper readiness event and never re-armed —A13's defect in the one path nothing had ever sent a large inbound burst to. kqueue's level trigger hides it entirely; on epoll the edge is spent, and the stall needs the client to STOP SENDING, which is exactly what the inbound window above makes it do. So the fix above exposed a bug that had been waiting for it. Re-armed only on a full staging buffer, so an ordinary small-message socket pays no extra syscall.
Added
--idle-timeout, exposing the connection idle deadline that was previously aServerConfigfield with no flag or environment variable.poe smoke-idle-timeout,poe smoke-realtime-holds,poe smoke-ws-inboundandpoe check-phase-stamps— four new gates, each sabotage-proven.- Every probe now stamps the PHASE it was proving, so a traceback naming a
shared socket helper says which phase failed rather than only which call.
scripts/phase_stamp_check.pyholds it across all 16 probes and reverts each rule to prove the checker bites.
Changed
- A client that sends without ever reading, against an app that echoes, now BLOCKS rather than losing data. That is the correct end of a deadlock every echo server has, uvicorn included; the old behaviour only avoided it by discarding the client's messages.
docs/SPEC.mdgrew to 149 rows. Autobahn|Testsuite was run once by hand to decide whether to wire it (I13): it scores the build with a known RFC 6455 §5.5.1 violation and the fixed build IDENTICALLY, because its fuzzing client always initiates the close and the bug was on the app-initiated path. The ROADMAP's claim that "the bar is unambiguous and the result is comparable" is withdrawn. It still found the close-code defect above, and outside its performance section now scores 240 of 247 — every remaining failure being the deliberateMAX_PENDING_BYTEScap (I17).B8split intoB8(h2spec) andB9(the PortSwigger desync scanner), bothout of scope: h2spec needs HTTP/2, whichA18refuses, and a pair-scanner has nothing to compare against a server with no proxy in front of it.
0.15.1 — 2026-08-30
Fixed
- A WebSocket the server closes now ends in a FIN, not an RST. When an
application sent
websocket.close(1000), the loop wrote its Close frame and closed the TCP connection in the same pass — before the peer could reply. The reply then reached a socket that was already gone, TCP answered with an RST, and that reset flushed the peer's receive queue, taking the FIN with it and, for a client far enough behind, the Close frame itself: against thewebsocketslibrary at 200 concurrent closes, 33 of 200 sawConnectionClosedError: no close frame received or sentinstead of the application's own code 1000. The loop now follows RFC 6455 §5.5.1's order — having sent Close, it waits to receive one, bounded by a 2 s deadline so a peer that never replies cannot hold the slot. Measured after: 0 resets at 20, 50, 100 and 200 concurrent closes, and 200 of 200 cleancode=1000from the real client.ws_probe.pygained a close-order phase (64 concurrent closes, all required to end in a clean FIN) which reports 2 of 64 against the unfixed server.
Changed
poe stress-asgicovers the WebSocket path, in both loop modes. The pre-release timing gate drovechunked_keepalive.pyonly, so the seam a 2026-08-30 CI flake landed in — the WS path, the loop inversion and CPU contention together — was gated by nothing. Each round now runschunked_keepalive.pyand thenapps/asgi_bare/ws_probe.py, so the handshake lands on the slot the streamed connection just released, and the whole loop runs on the pump and again underM0_INVERTED=1(asserted from the banner, not assumed from the variable), atsmoke-asgi's 300 ms heartbeat so a timer is queueing frames into the outbox the application is filling. A failure names its mode, round and probe, andM0_STRESS_MODES=invertedreruns just the half that failed. Reverting thewebsocket.sendcredit gate fails the new gate on round 1 — 15 of 400 frames — and passed the old one 30 of 30. It did not reproduce locally (150 rounds per mode across three runs, up to 40 CPU hogs on 10 cores, all green) but did reproduce on CI, where the probe's new phase stamp named it at once: see "A WebSocket close races the peer's close reply" under ROADMAP's Recently resolved — it was diagnosed and fixed in this release.ws_probe.pyreports the phase it failed in. The CI failure was an unhandledConnectionResetErrorwhose traceback namedrecv_exact, a helper four phases share. A reset is now a finding carrying its phase — and, being anOSErrorrather than anEOFError, it no longer bypasses the flood phase's frame-count diagnosis in silence. It earned its keep immediately: the next occurrence named the app-initiated close handshake, which is a different phase from the one two investigations had assumed, and is what identified the underlying bug.
0.15.0 — 2026-08-29
The Mojo-native release: the tier for handlers written in Mojo grows the
ergonomics it lacked, and the HTTP layer under everything gets measurably
faster. Nothing on the wire changed — every existing response is
byte-identical, which the parse-sensitive smokes assert — and nothing an
application already does breaks: HTTPService implementers that spell
out all nine methods keep compiling.
What is visible to an application:
- A handler is
funcand nothing else.HTTPService's other eight methods carry defaults, soapps/hellois 30 lines instead of 57 and 268 lines of empty stubs left the tree. Adding a defaulted hook no longer breaks every implementer — which is howdirect_jobarrived. m0_http.reply(json,html,problem,redirect,empty,no_content,body_string,param_int, …) andRouter.allow_header— the helpers three apps had each rewritten.MojoPool: the handler pool for Mojo handlers, so one blocking Mojo handler no longer stalls the loop (fast-route p50 405 ms → 0.3 ms with two 400 ms blockers in flight).M0_INVERTED=1, experimental: the Mojo loop inside the executor's asyncio loop on one thread. Correct under every gate, −14% CPU at low concurrency, not the default — the CHANGELOG entry below says exactly why, with the numbers, and the one limitation to know before trying it (a request mid-await at SIGTERM is answered at the 5 s drain deadline).docker stopunder traffic costs about the slowest in-flight request, not 5 s: a keep-alive connection answered during the drain no longer holds the drain to its deadline.
And under it all: the request parser went from 1.96 to 0.86 µs and the
per-pass outbox sweep is skipped while nothing streams, which together
take apps/hello from ~122k to ~157k rps at c16 on the reference
machine (+29%), the ASGI executor from 55.7k to 60.1k on the benchmark
page's row (1.03x uvicorn --loop asyncio, above it for the first
time), and M0_INVERTED=1 to 62.0k. One wrong answer was fixed on the
way: a bare-LF line ending followed by a CR within 64 bytes used to
swallow the next header.
The one API that moved is inside the fork: lightbug_http's
HTTPHeader is four offsets into the parse buffer rather than two
Strings. Nothing outside packages/m0-http imported it.
Changed
-
The per-pass outbox sweep is skipped while nothing streams — except under the pump. Every pass swept all 1,024 slots for a streaming connection to drain, and the miss path alone cost 1.2 µs per pass.
OffloadLoopState.streaming_hint— an upper bound raised by the two sites that set a stream flag and recounted by the sweep itself — now gates it:apps/hello+3.3% at c16 (152.3k → 157.4k rps, +5.5% per core),M0_INVERTED=1+4.6% (59.3k → 62.0k). The pump's loop keeps sweeping every pass (OffloadPool.sweep_every_pass, set by its wiring alone), because measured without it the pump lost 2.9% rps at +6% CPU at c16: the microsecond was accidental pacing — a loop thread that parks sooner batches fewer submits and wakes the executor more — and ±0 at c256. Guarded by the streaming smokes, sabotaged three ways (never sweep; either flag site not raising the hint), each failing exactly the smoke it should. Artifacts underbench/results/outbox-sweep/. The follow-up the finding named — an explicit pause in place of the accidental one — was measured the same day and recorded, not built (ROADMAP "Pacing the pump's loop thread"): a 1.2–2 µs spin on every pass reproduces the sweep exactly, a spin only before a partial flush does nothing, and re-polling to merge batches is worse; the pump keeps the pacing it already has. -
The request parser is under a microsecond.
parse_request_headerson the twelve-header browser GET: 1.96 → 0.86 µs, the whole user-space request 3.33 → 1.97 µs (−40%),find_header_end45 → 16 ns, andOK()construction 0.66 → 0.52 µs as a side effect (scripts/bench_http_parts.mojo, medians of three; the instrument gained a warm-up pass, because once the parse got cheap its row — the first heavy loop — was reading the allocator's cold start). Four changes, each measured on its own first: the SIMD scanners name the first matching lane with aselectofiotaand onereduce_mininstead of a scalar walk over up to 64 lanes (9.4 → 0.8 ns per chunk), and run 64 lanes wide, then 16, then scalar, so the last headers of a request no longer fall to atry_peekper byte; the 100-entry offsets array is uninitialized rather than filled (3.2 KB of stores per request — 0.3 µs in context, though 66 ns measured alone); theHeadersblob and index are sized once from the bytes consumed and the field count, and a value goes in as oneextendrather than a byte at a time; and the wrapper's three post-loop RFC scans (Host, the Transfer-Encoding/Content-Length pair, the last-coding rule) are flags set in the loop that already dispatches on the name — the Host one had been building aStringto measure it.On the wire, byte-identical responses:
apps/hellounder wrk with browser headers, old and new builds side by side, 122.1k → 150.2k rps at c16 (+23%) and 123.1k → 152.1k at c64, p50 113 → 90 µs (bench/results/parse-lever-ab/hello-wrk-parse-ab-*.json); through the ASGI executor on stdlib asyncio, 55.7k → 60.1k (+8%) — see theM0_INVERTEDentry below for what that did to the inversion's gate.One answer changed, and it was wrong before: the wide scan looked for the first CR and only then for any other control byte, so a field value ended by a bare LF ran on to the next line's CR whenever one lay in the same 64-byte chunk — the next header vanished into the value. The mask is now the scalar tail's own predicate, so both widths agree (
test_a_bare_lf_ends_the_line_even_with_a_cr_further_onfails on the old scanner). Ten more tests sweep a line ending across every offset from 1 to 140 bytes for values, names and request targets — every hand-off between the three widths, both sides — and pin the invalid-versus-incomplete verdicts of the token scanner, which are the byte-at-a-time loop's exactly (a header line with no colon is invalid, not "still arriving"). -
run_event_loopis now three functions and a struct, with no change in behaviour:prepare_loop(the registrations and slot tables, returning aLoopState),_run_pass(everything between onebackend.waitand the next; returns whether the shutdown pipe fired) and_run_shutdown(the drain). The pass and the drain are the former inlinewhilebody, moved verbatim behindrefbindings into the state, so the nine helpers' 20- argument signatures are untouched. This is the step the loop inversion needs — a pass that something other than thatwhilecan call, one per asyncio readiness callback on the executor's own thread — and it is landed on its own so the zero-diff claim is checkable in isolation: the full suite, the warning ratchet (68, baseline) and the nine seam smokes (shutdown, ASGI streaming with its RSS guard, the blocking pool, the counter under prefork, pipelining, the Mojo pool, hybrid mounts, the Django WebSocket hold) pass unchanged, andstress-asgiN of N. -
Header parsing no longer builds a
Stringper name and per value.HTTPHeaderholds four offsets into the parse buffer instead of twoStrings that existed only to be read back as bytes and copied into theHeadersblob — two copies of every header per request, the first made to be the source of the second. Measured with the newscripts/bench_http_parts.mojoon the twelve-header browser GET:parse_request_headers2.52 → 2.05 µs, the whole user-space request 3.74 → 3.31 µs (−12%). The instrument is the point as much as the number: SERVER_PERFORMANCE.md's "allocations are invisible" verdict came from loopback sampling at 50k rps, and at 116k rps the parse turns out to be two thirds of the user-space request. Nothing on the wire changed; the eight parse-sensitive smokes and all m0-http tests pass unchanged.parse_http_versionalso stopped allocating a list of the literal it compares against (39 ns, fixed because it was silly, not because it showed). -
HTTPServicenow requires onlyfunc. The trait's other eight methods carry default bodies, so a handler writes the hooks it uses and nothing else.apps/hellowent from 57 lines to 30 — 4 lines of handler had been carrying 25 lines of empty stubs — and 268 lines of the same boilerplate came out of the five demo services inservice.mojo, all six Mojo apps and the README example. Nothing on the wire changed: every stub deleted was byte-identical to the default replacing it, andsmoke-hello,smoke-notes,smoke-counter,smoke-todo,smoke-ws,smoke-chatandsmoke-clientpass unchanged.Overriding a default is ordinary — define the method and yours wins. The practical effect is on the trait itself: adding a hook with a default no longer breaks every implementer in the repo at once, which is what the old contract's warning was about. Adding one without a default still does.
-
Router.allow_header(path)builds a 405'sAllow:from the routing table.apps/notes_apihad been probing the router once per method over a hardcoded["GET","POST","PUT","DELETE"]list — fivematchcalls to answer a question the table already knew, and a route registered in any other method (PATCH,HEAD) was silently missing from the header it produced.matchandallow_headernow share one_path_matches, so the two cannot disagree about which routes a path reaches;Router.method_ofreads a registration back. The headersmoke-notesasserts byte for byte (GET, PUT, DELETE, OPTIONS) is unchanged.
Added
-
M0_INVERTED=1— the loop inversion, experimental and behind the variable. For an unmounted, pool-free ASGI application, the Mojo event loop runs INSIDE the executor's asyncio loop on one thread: the backend's kqueue/epoll fd is registered withadd_reader, one readiness callback runs one non-blocking pass, a request reaches the app throughWSGIHandler.direct_jobwith no datagram, and its response reaches the wire throughservice_direct_completionswith no wake. Every other topology stays on the pump, unchanged.Correct, proven:
smoke-asgi(0 KB RSS over 10k requests),-fanout,smoke-django-asgi,smoke-fasthtmlandstress-asgi(30/30 under 20 hogs) all pass under the variable, on kqueue and — verified in a Linux container before CI,scripts/epoll_inverted_check.sh— on epoll; CI runs the ASGI smoke under it on both platforms. Two single-thread rules were found on the way and are documented onExecutorPort._place_frameandPyBridge.notify_disconnect: a producer that waits for the loop to drain waits for itself, so a full chunk channel is drained by running a pass; and a direct job would overtake a disconnect still on the FIFO submit channel, so the disconnect goes direct too.Not yet a throughput win, and the numbers say why. Same session, uvloop executor, c16, two samples of three rounds: inverted 59.1–59.6k rps at 0.87–0.88 cores (p50 263 µs), pump 62.6–63.1k at 0.98 (p50 237 µs). The two cross-thread wakes per request are gone — that is the −11% of CPU — but the pump's two threads were also overlapping Mojo parse/write with Python app work, and at c16 wrk is a closed loop, so +27 µs of serialized latency is −6% rps. Per core it is +5%; on stdlib asyncio (the gate's row) it is +1% rps at −10% CPU, +12% per core, with both arms at 0.93x uvicorn asyncio. The default therefore stays the pump; the flag is the A/B,
bench_asgi_wrk.shnow recordsinverted=in its artifact, and the session's eight A/B artifacts are underbench/results/inverted-ab/rather than the canonical glob the benchmark page renders from.Re-measured after the parser change above, on the gate's own row (stdlib asyncio, c16, medians of three, uvicorn asyncio beside each arm;
bench/results/parse-lever-ab/): pump 55.7k → 60.1k rps, 1.03x uvicorn asyncio; inverted 54.5k → 59.7k, 1.01x, at 0.88 cores (67.9k/core against uvicorn's 59.6k). On uvloop: pump 63.2k → 69.1k (0.83x uvicorn uvloop), inverted 60.2k → 66.4k at 0.86 cores, 77.2k/core (0.79x on rps, 0.92x per core). Both arms now clear the gate; the inversion's edge over the pump is still per-core (+9% and +12%), not throughput (within noise, and −4% on uvloop), and the default is unchanged.Evaluated for the default and declined, on two more measurements: at c256 the per-core edge is gone (pump 88.1k @1.02, inverted 85.5k @0.99 — +0.6%/core, −2.5% rps, on an idle machine with the comparators within 0.5%), so it does not buy capacity; and under the flag a request mid-await at SIGTERM is answered at the 5 s drain deadline (5.30 s for a 1.5 s request, where the pump answers at 1.50 s), because the drain is still the blocking first cut. That limitation is recorded beside the flag in
m0serve.mojoand as ROADMAP design item 6, deferred to the inversion's promotion bar rather than built for a mode nothing runs; an inverted server wants a stop grace of 10 s or more. -
A handler pool for Mojo handlers (
lightbug_http/mojo_pool.mojo):MojoPoolputs N handler threads behind one event loop for anHTTPServicewritten in Mojo, the way--blocking-threadsdoes for a WSGI app — no interpreter, no GIL, noDetachingBackend. A handler conforms toPoolHandler(two methods:make,shutdown; the rest isHTTPServiceand its defaults) and the loop becomes an acceptor:Server.listen_and_serve_nonblockingnow carries theoffload_addrparameterrun_event_loopalways took andServerdropped.Measured (
apps/pool_spike, three runs, M4): p99 of/faston a keep-alive connection beside N handlers blocking 400 ms in a syscall —configuration slow=0 slow=1 slow=2 slow=6 loop only 0.1 ms 406.0 ms 405.9 ms 2026.5 ms pool of 4 0.2 ms 0.2 ms 0.3 ms 404.0 ms At
slow=2the loop-only p50 is 405 ms — every request, not a tail. The last column is the deliberate saturation boundary (6 blockers against 4 threads): the pool degrades to about ONE blocking duration (p50 31.8 ms) where the bare loop degrades to the queue's sum (bench/results/pool-probe-20260828T175956Z.json). Deliberately only for handlers that block: CPU-bound work already parallelises inside one handler withstd.runtime.asyncrt'sTaskGroup(measured 3.6x on four tasks), so the pool exists for threads parked in a syscall. A streaming response from a pool thread is refused with 409 (the loop drains its own handler's registries, not a pool thread's), andbefore_requestruns on both the loop's handler and the pool thread's — the loop's is where an always-responsive/healthbelongs.Guards:
test_mojo_pool.mojo(intest-http),poe smoke-pool(in CI: which thread served, saturation behaviour, clean SIGTERM),poe sabotage-pool(in CI on Linux, where all four rules are observable — closing the submit channel wakes a blockedrecvon macOS and not on Linux), andpoe probe-pool, the pre-release p99 table with a deliberate saturation column. -
m0_http.reply— the response constructors every Mojo app was writing for itself.apps/notes_api,apps/datastar_todoandapps/datastar_countereach carried their own_json,_html,_no_contentand_parse_id, the last two byte-identical copies. The module holdsjson,html,empty,no_content,redirect,problem(RFC 9457),vary_accept,accept_header,body_stringandparam_int, lifted from those bodies rather than invented, and all three apps now use it —apps/notes_apiwent from 388 lines to 308.redirectis new surface:common_response.mojoshipped onlySeeOther, so 301/302/307/308 had no constructor at all.One behaviour change came with it.
param_intrefuses a parameter longer than 18 digits; the hand-written copies multiplied without bound, so/notes/99999999999999999999wrapped to some other note's id. Every caller already treats-1as a 404. -
packages/m0-http/test/test_service.mojo— the guard for the above, and the first unit coverage the handler contract has had.MinimalServiceimplementsfuncand nothing else, so the file failing to compile is the failure; the value assertions pin that the defaults return what the event loop expects (no short-circuit, an empty drain, a non-streaming slot), since a wrong default would be a silent behaviour change across every app rather than a compile error. Proven by reverting each of the eight defaults to...in turn and confirming the suite fails for every one.
Fixed
- A keep-alive connection answered during the drain no longer holds
the drain to its 5 s deadline. The graceful shutdown closed the
connections that were already idle when SIGTERM landed, but a request
still running at that moment — on a pool thread or the executor —
completed during the drain, went out in one
send, registered no write interest for the drain'sEVFILT_WRITE-only dispatch to see, and re-armed its slot for a next request the drain never reads;active_countthen held at one until the budget ran out. Measured with a 1.5 s request in flight at SIGTERM: the response at 1.5 s either way, but the process exited at 5.35 s with keep-alive against 1.55 s withConnection: close, in every execution mode. The between-requests sweep now runs after every completion pass of the drain as well as before it (_close_between_requests); the process exits 0.04 s after answering.smoke-shutdowngained a fourth phase on the Mojo pool (scripts/drain_inflight_probe.py), which fails at 5.33 s with the in-drain sweep removed.docker stopduring traffic now costs about the slowest in-flight request rather than 5 s.
0.14.1 — 2026-08-28
A hardening release for the ASGI streaming seam. No wire format, default
or API changed, and one thing is visible to an application:
websocket.send now applies backpressure. An app that sends faster
than its client reads waits, where before it silently lost messages:
430,693 of 1,638,400 bytes arrived under a clean close frame, a message
stream with holes the peer had no protocol-level way to detect. The same
flood now arrives whole, byte for byte.
The rest turns failures that were invisible into failures that are named
and terminal, and adds the two guards the 0.14.0 slot-ownership fix
shipped without. If you run ASGI streams or WebSockets under m0serve,
this is worth taking; if you run WSGI only, nothing here reaches you.
Added
- The slot-ownership race has guards. 0.14.0 fixed a silent hang in
the ASGI executor — a stream on a recycled connection slot could leave
the loop holding a subscription with no producer, which a client saw as
a 30 s stall against a clean server log — but the fix was verified only
by an ad-hoc reproducer, so nothing would have caught a
re-introduction. Two guards now do:
poe test-shim(in CI, insidetest-all) exercises the shim's ownership rules with no server, no Mojo and no threads:scripts/shim_ownership.pyextractsSHIM_SOURCEfrombridge.mojo,execs it, and drives it through real socketpairs exactly as the event loop does. Seven tests, one per rule; four of them fail on the shim reverted to its pre-fix ownership shape.--sabotagereverts each of the eight rules in turn and insists the suite fails for every one, so the repo's sabotage rule is enforced rather than remembered.poe stress-asgi(a pre-release check, deliberately not in CI) runschunked_keepalive.pyN times under CPU hogs — the shape that recycles a slot mid-stream. Reverted build: failed on round 5 of 15. Current: 45 of 45 across three runs.
check-docsfails when atest-*poe task is not reachable fromtest-all, which is the single step CI runs. Itssmoke-*twin exists because a smoke once shipped that CI never ran; a test task can drop out the same way, with no ghost step to notice.
Fixed
-
Six silent failures in the streaming seam are now terminal and named. A frame the seam could not place was discarded at five of six sites, and a drain ack could credit the wrong stream. Each was measured by forcing the failure:
- a dropped stream begin frame served a clean, EMPTY 200 (the log
naming only a downstream
KeyError) and left the application's task awaiting credit for the life of the process; it now answers 500, closes, and cancels that task through the same disconnect tag the loop would have sent. - a dropped stream end frame hung the client until its own timeout (12 s, curl exit 28) against a silent log; it now aborts — the body's bytes, then a close with no terminator — in 13 ms, and says so.
- a response chunk the loop's outbox refuses aborted nothing and hung the connection for 12 s; it now aborts, so the truncation is visible to the client.
- a WebSocket frame the outbox refuses delivered 430,693 of 1,638,400 bytes under a clean close frame — a message stream with holes the peer had no way to detect. It now flushes what is queued and closes abruptly (1006).
- the WebSocket begin frame and the close/end pair get the same
treatment — and a socket can now be aborted at all: the loop's abort
path gated on
slot_sse, which a held 101 never sets, so aborting a socket was a silent no-op. It readsslot_sse or slot_wsnow, and a 101 records its generation after the non-stream branch that was clearing it. - a stale drain ack — acks name a slot and carry no generation, and the loop recycles a slot the instant it closes a connection — could credit a recycled slot's new stream past its window, over-committing the one chunk channel every stream on an executor shares. Credit is now clamped to the window.
A tear-down is claimed once per stream (
stream_lost): the producer does not learn its connection is gone until the loop closes it, so one flooding WebSocket announced itself 336 times before. - a dropped stream begin frame served a clean, EMPTY 200 (the log
naming only a downstream
-
websocket.sendapplies backpressure. It was the one path above reachable by an ordinary application, and until now it was not credit-gated at all: an ASGI app that sent faster than its client read filled the loop's 64 KB per-slot outbox and every frame past it was dropped — 430,693 of 1,638,400 bytes under a clean close. It now waits for drain credit exactly as a streaming HTTP response does, so the same flood arrives complete and in order (measured byte for byte: 400 x 4 KB plus framing, 1,640,193 bytes on the wire). Almost all of the machinery was already running — the loop acks a socket's drained bytes, because a WS slot on an executor lane answersslot_channel_stream— and what was missing was the window to credit them to, seeded atwebsocket.accept. Credit is charged in ENCODED frame bytes, which is what the loop acks; charging the payload instead drifts by the header on every message, threefold on one-byte sends.apps/asgi_baregrew a/ws/floodroute andws_probe.pya phase that asserts the exact count, sosmoke-asgifails if the gating is removed (measured: 15 of 400 frames arrive).Two limits this does not lift, both now loud rather than silent: a single WebSocket message larger than
MAX_PENDING_BYTES(64 KB) is still refused by the outbox, since the cap applies to one frame as well as to the queue; and a--realtimehold on a WSGI lane has no window, because the loop does not ack those sockets.
0.14.0 — 2026-08-27
A streaming and throughput release. Three changes are visible on the wire
or in your terminal, so read them before upgrading: unsized WSGI bodies (a
generator, Django's StreamingHttpResponse) now stream chunked where
0.13.0 buffered them into one sized response; a second m0serve on a busy
port now fails to bind instead of silently sharing it, SO_REUSEPORT
having become opt-in; and m0serve's startup output is one line of its
own, printed after the application loads, so "ready" means ready.
The ASGI executor went from 0.72x to 1.06x uvicorn --loop asyncio on
the benchmark page's 16-connection row — 1.22x at 256 — by inverting the
pump: Python calls into Mojo through a type built in-process, and the
executor thread never leaves its event loop. And one silent hang is fixed:
a stream on a recycled connection slot could leave the loop holding a
subscription with no producer, which a client saw as a stall on a clean
server log.
Added
-
check-docscounts the tests in the tree (def test_perpackages/*/test/*.mojo) and fails when README's "What's in the box" table or itstest-allcomment says otherwise; the table sat at 618 while the tree held 928. -
Unsized WSGI bodies stream. A generator or iterator the application did not size — Django's
StreamingHttpResponse, a FlaskResponse(generator)— is produced chunk by chunk from a--blocking-threadspool thread (the WSGI zero-config default) through the same chunk channel the ASGI executor streams through: 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. Every release before this joined such a body whole, so a never-ending SSE generator never answered and pinned its thread until shutdown. Sized bodies (Content-Length— every Flask page, every Django page behindCommonMiddleware,FileResponse), list bodies, Django'sHttpResponse, HEAD, bodiless statuses andM0-Holdresponses buffer exactly as before, so no framework page changes on the wire; a server with no pool keeps joining.poe smoke-wsgi-streampins the contract. -
apps/wsgi_baregains/stream,/stream-forever,/stream-raises,/stream-empty,/stream-write-inside,/stream-cland/stream-hold;apps/django_wsgigains/events, aStreamingHttpResponse. -
poe bench-asgi-wrk/scripts/bench_asgi_wrk.sh: the wrk run behind theasgi-wrk-helloartifact, which had no producing script. It puts the venv first onPATH(so a bare invocation embeds the same interpreteruv run poedoes), stampsexecutor_pythonandexecutor_loopin the artifact, takesM0SERVE_BINfor an A/B of two builds andBENCH_CONNSfor the concurrency; the generated block on the benchmark page prints the executor's loop.check-docsnow ratchets the uvloop ratio and per-core gap too.BENCH_NAME,BENCH_APP_DIR,BENCH_M0_SPEC,BENCH_UV_SPECandBENCH_PATHrun a framework app through the same script: theasgi-wrk-fasthtml-*andasgi-wrk-django-*artifacts behind WSGI_PERFORMANCE.md's framework table (FastHTML 0.85x / Django ASGI 1.18xuvicorn --loop asyncio). -
bench/results/asgi-wrk-conns-*.json: the 2026-08-27 concurrency matrix — the executor with and without pump batching, and on uvloop, at 16/64/256 connections.
Changed
m0serve's ready line carries the Mojo flame:🔥 m0serve: app:application on http://…, and both READMEs now document it as the contract it is — printed once per worker, after the application is imported, so "it printed" means "it is serving". There is no cross-server standard to match here (uvicorn saysUvicorn running on …, gunicornListening at: …); for an orchestrator use--health-path /healthor a TCP check rather than the log line. One emoji, once per worker, on the line that already means "ready" — the fork's🔥🐝 Lightbug is listeningbanner it replaced was Lightbug's branding (attribution lives in NOTICE) and printed before the application loaded. Error and shutdown lines stay plain: a flame on a failure reads as celebration. The Mojo example apps keep the fork's banner unchanged.- The loop↔executor pump is batched in both directions. The loop
sends a pass's submits to an executor lane as one datagram at the
bottom of the pass, and the executor answers a pump pass's completions
with one datagram; every existing ordering (begin frame before head,
park before poke, pill FIFO behind every job) is preserved by
construction, and a batch the channel will not take runs inline, which
is what a refused submit always meant. Measured under wrk with the
uvicorn rows re-measured beside it: +5% at 16 connections
(a pass batches three submits on average there), +7% at 64 and +19% at
256, where the executor passes
uvicorn --loop asyncio(1.10x; 0.85x against uvicorn with uvloop). Table and artifacts in docs/WSGI_PERFORMANCE.md;bench-asgi's stdlib throughput gate is retired — its harness read 1.4x the same day wrk read 0.75x, so it measures its own client; the ratio is printed as information and the mixed-tail gate stays. - The executor pump parks in
run_forever, onestop()per pass, instead of arun_until_complete(batch())per pass (38 µs on stdlib asyncio against 17): every shim event is appended to a list, the first append while the pump is parked schedules the stop for the end of the next iteration, andwait_eventsreturns the list. Measured on top of batching: +16% at 16 connections (50,747 rps, 0.90xuvicorn --loop asyncio), +18% at 64 (67,258, 1.17x), +2% at 256. A stop is armed only while the pump itself is parked — never insidefinish_executor's post-pill gather orlifespan_shutdown, which it would end early with "Event loop stopped before Future completed", skipping the application's shutdown;smoke-asgi's new outlive-the-drain phase pins it, andapps/asgi_barewritesM0_SHUTDOWN_MARKERfrom its lifespan shutdown so the phase can tell. - Python calls into Mojo for every executor event; the executor thread
never leaves
run_forever.ExecutorPortis a Python type built withPythonModuleBuilderinside the embedded interpreter (no shared library, noPyInit_, no ctypes; ~70 ns a call) and set into the shim as_port; every event that used to be queued for a Mojo pass is_port.dispatch(ev), handled at once inside the loop iteration that produced it, and completions are poked to the loop once per iteration by acall_soon-scheduled_port.flush. The per-passrun_until_complete(38 µs on asyncio, 64 on uvloop) is gone. Measured beside therun_forever+stop()pump: on stdlib asyncio within noise at 16 connections (49,713 rps, 0.93xuvicorn --loop asyncio) and 1.07x / 1.13x at 64 / 256; on uvloop, which this shape finally lets pay, 60,419 rps at 16 connections — +24% over the pump on the same loop, 1.05x the asyncio comparator, 0.74x uvicorn with uvloop — and 0.94x uvicorn with uvloop at 256.bench_asgi_wrk.shgainsBENCH_EXECUTOR_PYTHON=systemfor an A/B of the executor's loop. - The benchmark page's ASGI row is re-measured (0.72x → 0.75x
against
uvicorn --loop asyncioat 16 connections, executor on uvloop) and now also states the uvloop number a defaultpip install uvicorn[standard]produces (0.53x). Which loop the executor ran on was never recorded before:bin/m0serveoutside the venv embeds the system Python and runs on stdlib asyncio — measured to be a wash either way (−3% at 16 connections, +4% at 256).
Fixed
-
A stream on a recycled slot could stall silently. The executor's per-slot state (credit window, event, disconnect mark) was keyed by slot alone; the loop recycles a slot the instant it closes a connection, and the previous task lives on for an iteration or two. When the HTTP/1.0 client of
chunked_keepalive.pyclosed after the head and the keep-alive stream that followed landed on the same slot, the new task saw the OLD connection's disconnect mark, cancelled its own stream, and — the mark being the slot's — skipped its end signal, leaving the loop a subscribed stream with no producer: the client waited 30 s on a clean server log (CI macOS, 1 in 2; 8 of 11 runs under twelve CPU hogs locally). A stale task's late cleanup could also wipe the live task's window. Now a slot's state belongs to the slot's current task (_exec_slot_task): cleanup only by the owner, a disconnect stamped on the task it hit (with the dead connection's in-flight bytes refunded there), a stale mark cleared when a new task takes the slot, and every "am I gone" check asking both. 0 of 6 under six CPU hogs (the plain build: 4 of 5) after, same load. -
A second
m0serveon a busy port fails, loudly, instead of binding beside the first.SO_REUSEPORTwas set unconditionally on every listener, so a second server on an occupied port bound successfully, printed "Ready", and on Linux took a share of the connections (17 of 40, measured from the wheel inpython:3.12-slim; macOS: served nothing). The option is now opt-in onListenConfig(reuse_port=False; workers and threads share one pre-fork listener and never needed it), andm0servebinds with five attempts a second apart — a restart racing the previous process's 5 s drain still succeeds — then exits 1 withaddress already in use: HOST:PORT -- is another server running?.smoke-servepins it. -
"Ready" means ready. The listener's banner printed before the application was imported, so a failed import logged "Ready to accept connections" and then exit 1.
m0servenow binds quietly (ListenConfig(quiet=True)); its own startup line, printed after the load, is the ready signal. -
A load failure shows its traceback. An application whose import raises (a settings module without its environment variable, a missing dependency) printed only the exception's one-line text. The shim now attaches the Python traceback for every failure that is not the spec's own module or attribute being absent — those stay one line, because a bare
MODULEtries four discovery candidates and the misses must stay quiet — and discovery stops at a candidate that exists and raises instead of trying the next convention.apps/wsgi_bare/deep_failis the case;smoke-servepins it and the quiet misses. -
A stream that raises after its head is truncated honestly. The connection closes without the chunked terminator — for a WSGI generator and for an ASGI application alike; the executor used to end such a body cleanly, which made a short body indistinguishable from a complete one.
-
A chunk that outlived its connection can no longer land in a recycled slot's new stream. Every stream frame carries its stream's generation and the loop handler drops one that is not the subscription's. One producer's frames are FIFO behind its own begin; with two producers on one loop (an executor and a pool thread, two executors, or a hold arriving on the other bus fd) there was no order between them at all.
-
A stream whose head completes during the shutdown drain is now told goodbye. The drain loop dispatches write-readiness only, so such a connection was never closed and its producer waited out the bounded join as a straggler; a second farewell pass after the drain closes it.
0.13.0 — 2026-08-27
A security-audit release plus two wire-protocol conformance fixes. Several requests that previous versions accepted are now refused, and one class of client that previous versions hung is now served — read Changed before upgrading anything that speaks non-standard HTTP at this server.
Security
- A cross-connection injection hole is closed. Channel names opening
with the reserved
\x01byte address connection SLOTS on the loop, and an application channel is frequently user input (%01in a form body decodes to the control byte): an unauthenticated POST could reach another client's SSE stream throughpublish(). Every publish boundary now refuses reserved and over-long names —publish_to_channels, the shim's_M0Broadcast, and both copies ofm0pub.publish_frame. /ws/messageis the server's path, not an application's. Under--realtimeit carries synthetic POSTs with trustedM0-*headers and a CSRF exemption; a request for it arriving over the wire is now 404, so only the in-process synthetic one reaches the app.- Response headers carrying CR, LF or NUL are dropped, not transmitted (and an injected status reason phrase is emptied) — a header an application built from user input can no longer end the header block and add headers or a body of its own.
Proxyrequest headers never reach WSGI/ASGI environs (httpoxy): CGI's mechanical mapping would turn them intoHTTP_PROXY, the variable outbound HTTP clients read to choose a proxy. Nothing else is dropped —X-Forwarded-*is load-bearing behind a real proxy.- Static mounts serve only regular files, auth token comparison runs in constant rounds, WebSocket datagrams are bounded, and the WS outbox drops whole frames past a 64 KB cap instead of growing unboundedly.
Fixed
- Pipelined requests are all answered, in order (RFC 9112 §9.3).
Every release through v0.12.0 answered only the first request of a
pipelined burst and left the client hanging on the rest: the keep-alive
reset cleared the receive buffer, and bytes consumed together with a
previous request get no readiness event of their own. The request's end
is now stamped at dispatch, the keep-alive reset preserves the tail
(and ONLY the keep-alive reset — a tail can never leak across
connections), and a drain loop answers buffered requests after every
completed response, on both backends and on the blocking path.
poe smoke-pipeliningpins it. - A client that half-closes (
shutdown(SHUT_WR)) after sending gets its response. kqueue reports a half-close asEV_EOF, and closing there discarded the response already written: macOS lost 24–30 of every 30 requests on GET, Content-Length and chunked alike; Linux lost none. The loop now finishes the buffered request and only turns off keep-alive.poe smoke-half-closepins it. - The epoll backend registers
EPOLLRDHUP, ending the half-close divergence. A half-closed INCOMPLETE request now releases its slot promptly on Linux too, where it used to be indistinguishable from a silent client and waited out the full 10 s header timeout for a 408. The guard is layered — a recv returning 0 marks the peer gone even where the flag is absent — and a half-close while a request is out on a--blocking-threadspool thread no longer detaches the fd (which dropped the response the pool thread was about to complete): the completion answers through the still-open fd. - A request bigger than one read is answered on epoll too. The header
path performed one recv per readiness edge and never re-armed, so a
request over 8192 bytes — a large cookie jar or a JWT, not an attack —
stalled on Linux until the header timeout answered 408. Headers now
re-arm exactly as the body path always did; requests are served up to
the 32 KB header cap and answered 431 beyond it.
poe smoke-large-requestpins the boundary. - A chunked request body is decoded incrementally by one persistent decoder per connection. Rebuilding the decoder per read event made a dribbled chunked body O(N²) on the loop thread — 3 MB took 1.37 s, 0.006 s after — and reset the decoder's own abuse-ratio guard so it could never trip.
- The chunked terminator is consumed (
consume_trailer), so closing aConnection: closesocket no longer leaves the final CRLF unread — which made the kernel send RST instead of FIN and discarded the response, measured at up to 53% of chunked requests and 100% when the client paced its writes. - HTML escaping passes UTF-8 through intact (
escape_htmlin m0-core):caféno longer renders ascafé. - An application's
Set-Cookiegoes to the wire verbatim (subject only to the CR/LF refusal). Round-tripping it through the server's own cookie model silently droppedexpires,SameSite, everything after the first=in a value, and any unmodelled attribute — on every Django session and CSRF cookie of every app.
Changed
Requests that v0.12.0 accepted and this release refuses, or handles differently — each is a smuggling or correctness surface:
- HTTP/1.1 without a
Hostheader → 400. Hand-rolled clients and crude health checkers sometimes omit it. - Malformed
Content-Length→ 400 (5, 5,0x10,+5,-1,5abc, longer than 18 digits). Previously read as 0 and served as bodyless — which a proxy in front could read differently. Transfer-Encodingwhose final coding is notchunked→ 400.gzipalone was previously served as bodyless.- An encoded slash stays encoded:
unquotere-emits disallowed bytes as%XXinstead of deleting them, so/adm%2Finno longer collapses to/admin. - A chunked body is bounded by raw bytes consumed as well as decoded size — a body whose framing outweighs its payload roughly twice over now answers 413 (1 MB in 3-byte chunks is 3.6 MB on the wire).
- A chunked request that omits the final CRLF now waits for it, as it would for any truncated body, instead of being answered early.
Added
poe smoke-pipelining,poe smoke-half-close, andpoe smoke-large-request— socket-level regression probes for the fixes above, run on both CI runners because each bug was invisible on one platform.- The PyPI publish path is hardened: the
pypienvironment is the authorization boundary (deployment branch policy + required reviewer), the publishing action is pinned, and the wheel set is validated against the tag before upload.
0.12.0 — 2026-08-26
Added
-
WebSocket holds work with
--blocking-threadsand with--mount. The last thing--realtimerefused. A pool thread performs the 101 — the client's key is in the request it holds — and sends the loop anHframe carrying its own lane; the loop records which mount holds the socket, and an inbound frame rides the submit channel back as aTAG_WS_MESSAGEdatagram (the executor's shape plus the channel, since a pool thread's registries are empty).next_jobdecodes both shapes off one channel, told apart by length, into a buffer owned by the thread so a WebSocket's size is not charged to every request. Two orderings are load-bearing and both are pinned bysmoke-django-realtime-wsphases 3 and 4: the pool must be asked about a socket before the executor (on a mixed mounted server the executor's fd is set, and asking it first hands every message to an executor that never accepted the connection), and the "not pool-held" sentinel cannot be -1, which is a real lane. The whole socket probe — handshake, fan-out, relay through Django, channel isolation — now runs against a pool and against mounts, and behind two 1.5 s views it costs +13 ms against its own baseline. -
--realtimecomposes with--mount. One process can now hold the SSE streams a synchronous application publishes to and run an ASGI mount for the streams whose view is the producer — the shape a mixed application needs, and the one that kepttextshelfin two processes (docs/REAL_APP_VALIDATION.md). The loop tells a held stream from an executor's per slot rather than per server (OffloadPool.slot_is_executor, read from the lanesubmitalready stamps): asking globally was the same question only while the two could not share a loop, and a held stream drained as an executor's would be chunk-framed, acked to an executor that never issued the credit, and denied the comment heartbeat that keeps it alive through an idle proxy — none of which stops delivery, so none of which looks wrong.smoke-django-realtimephase 6 holds one stream of each kind on one loop and asserts both. Still refused: awebsockethold under--mount(409 — an inbound frame is a synthesised POST into one urlconf), and--realtimeon a server with no WSGI mount at all. -
--realtimecomposes with--blocking-threads. A hold taken on a pool thread is forwarded to the event loop's registries as a reserved frame on that loop's own bus channel, before the response completes — the executor's begin-before-head seam, applied to a second producer — so a held-stream server no longer has to run its views on the loop. On textshelf with eight slow views in flight that is the difference between a 1 543 ms fast-path p50 and 0.3 ms, and between M0-Hold being demonstrable and deployable. SSE holds only: a WebSocket hold under the pool answers a 409 that says why (inbound messages still reach the view on the loop thread), and--mountwith--realtimestays refused. The loop needs no ordering guarantee for the forwarded frame because, without an executor, it has no end-of-stream signal to misread a not-yet-subscribed slot as.smoke-django-realtimephase 5 pins the composition;smoke-blocking-threadsandsmoke-doctornow assert the pair is accepted where they asserted the refusal. -
The isolation benchmark has an artifact, and the ratchet caught sixteen stale sentences. Gate 3's last item.
bench/results/now carries amixed-workload-*.json— the pool's ~100x p99 claim, the largest effect in this repository and previously the only one with no machine-readable source — plus a post-pin re-run of the WSGI layer split.Re-rendering moved every derived figure, and
check_bench_prosefailed the build naming all sixteen prose sentences that had gone stale across README.md, docs/BENCHMARKS.md and docs/WSGI_PERFORMANCE.md, each with the value it claimed and the value the artifact computes. That is the whole reason it exists: the tables re-render themselves, and before this the sentences around them would have quietly kept the old numbers. Per-core ratio 0.83x → 0.85x, bridge tax 1.44x → 1.36x.Two findings the run itself produced. Granian's
--blocking-threadsrow is better than ours — ~0.6 ms flat against our best ~2 ms — which is now on the page, because the honest claim is that the pool removes a hundredfold stall, not that it wins the tail that remains. And the earlier note that granian "is not in this repo's lock file" was wrong: it is, in thebenchgroup at the pinned 2.8.1, oneuv sync --group benchaway. That is why its row had been missing from the isolation table.Recorded because it changes how these are read: one anomalous round per run is normal on this box. Three recorded layer-split runs each had exactly one round land well off the other two, in a different position each time, while their medians agreed to within 0.03 on the per-core ratio. Median-of-three is doing real work here, not ceremony.
-
The first screen leads with what the server is for. Gate 5. All three surfaces that have a first screen now open on the same claim — realtime from a synchronous Python app, with no added infrastructure — with the hero shown as six lines of ordinary sync Django rather than described: README.md,
packaging/m0serve/README.md(which is what PyPI renders, and is the one nothing was checking until this release), andllms.txt.The gaps are on the first screen rather than in an issue: no TLS or HTTP/2 (terminate at a proxy), the platform floors, pre-1.0, and — stated with numbers and a link — that this is not the fastest server on raw throughput. A page that hid that would be contradicted by the benchmark page two clicks away.
The snippet was run before it was published: a Django app containing exactly those six lines, served by
bin/m0serve --realtime, deliversid: 2 / data: deploy finishedto a livecurl -Nsubscriber. The fuller path stays covered in CI bysmoke-quickstart. -
docs/BENCHMARKS.md: the public benchmark page, and it leads with the losses. Gate 3 of the launch checklist. Four generated regions across two documents, all driven by
render_bench_docs.pyand all CI-checked — hand-edit a table, or land a new artifact without re-rendering, andcheck-docsfails naming the file.Two things it does on purpose. It states plainly that m0serve is ~0.83x Granian per measured core on bare WSGI and 0.72x uvicorn on ASGI throughput, because the win it does claim — fast-request p99 under mixed load — is only credible next to them. And it renders a stated absence for the mixed-workload bench rather than omitting it: the handler pool's ~100x p99 improvement is the strongest claim in this repository and currently the only one with no machine-readable source.
render_bench_docs.pygrew from one region in one document to a table of targets, with a renderer per bench kind. The isolation bench gets its own: its finding is a comparison across slow levels within one configuration, so the rows are pivoted — a flat row isolated the slow work, a climbing one did not — and the percentiles are re-medianed fromrows, sincebench_record.medians()folds only rps and cores. It also stops naming a comparator the artifact's environment stamp recorded but the bench never ran: on a public page, "granian 2.8.1" beside a table with no granian row reads as if it had been measured and lost. -
m0serve --doctor: the configuration as JSON, starting nothing. The launch checklist's machine-readable startup diagnostic, and the reason is narrower than "nice to have": every refusal this server makes already names its fix, but seeing one meant attempting the run — which binds a port, forks, and imports the application.--doctorreports platform and wheel architecture, the interpreter it resolved and the virtualenv it came from, the spec discovery chose and the protocol it classified as, the resolved topology (including whether the handler pool is a default or configured), and achecksarray whose failures each carrydetail,fixandexit.The contract is the exit code:
--doctorexits with the codem0serveitself would exit with for the same arguments — 0 serve, 2 usage, 1 startup, 78EX_CONFIG. A diagnostic that reports "fine" where the server refuses is worse than no diagnostic, and the doctor mirrors the startup path's check order rather than sharing its control flow, so nothing but a test keeps them in step:poe smoke-doctorruns both binaries over every refusal and compares.That test earned its place before it was committed. The first implementation recorded the free-threading check before the usage conflicts, so
--workers 2 --threads 2reported 78 where the server exits 2 — the interpreter is never reached, becausemaindecides topology conflicts before any Python runs.Report.exit_codereturns the first failure rather than the largest for the same reason, andtest_doctor.mojopins it.A bare
m0serve --doctorwith no application is the "is this environment sane" call and exits 0 — reporting the interpreter facts is precisely what a failed install needs, and previously libpython not resolving was visible only as a traceback at serve time.
Changed
-
The loop's
before_requestruns before a request is offloaded, not only on the queue-full fallback — where, under a pool, it never ran on the loop at all.WSGIHandleranswers its static mounts and the health path there, so under--blocking-threadsthose are served on the loop in Mojo rather than by a pool thread: a stylesheet stays readable whatever the pool is busy with, and/healthreports the registries the loop actually drains. Before this, under the newly-composed--realtime --blocking-threads, it reported zero subscribers while events were being delivered — a pool thread's own registries are always empty. -
The mounted-isolation guard had 138x headroom and now has 12x.
hybrid_isolation.py'sISOLATION_BUDGET_MSwas 400 ms against an observed p99 of 1.4–4.1 ms, so it discriminated "isolated" from "sharing an execution mode" (~2000 ms, the sync view's hold) and nothing in between: a regression that parked the async mount for 300 ms — a hundredfold degradation, plainly visible to a user — passed. It is now 50 ms, chosen from 17 recorded CI runs across both runners rather than from the gap to the failure signal, and the docstring carries that evidence and its one limit (every run is the prefork phase; the--threadsphase is skipped wherever there is no free-threaded interpreter with fasthtml).Every run now prints its headroom, pass or fail, because a number drifting from 12x to 2x is the warning that comes before the failure.
-
A total loss of isolation reported a stack trace. With the pool off (
--blocking-threads 0) the sync mount's warm-up never returns at all, and the script exited on a urllibTimeoutError— the smoke failed, but whoever read the log had to work out why from a traceback. A request that never returns is now reported as what it is, with the two things to check named. The ordinary failure still reports a number: sample timeouts are bounded well above the 2000 ms hold, so a request genuinely queued behind the sync work is measured rather than erroring. -
check_wheel_platform_claimsnow checks what its docstring always said. It asserted that a wheel gets built at all and that neither README points at 3.13t; it never compared a platform table to anything. It now reads theplat:entries out ofrelease.yml'sbuild-wheelsmatrix — the only place a wheel that reaches PyPI is declared — and holds both READMEs to them in both directions: a built platform must be marked supported, and a platform marked supported must be built. Either failure is a claim with no artifact behind it, which is the whole premise of this file.Recorded while wiring it, because it is the opposite of the guess:
test.yml'spaths-ignorelists*.md, and a GitHub path glob's*does not cross/. A PR touching only the rootREADME.mdtherefore skips CI entirely, while one touching onlypackaging/m0serve/README.mdruns the full suite. The published README is the guarded one. -
The quickstart's version echo is machine-checked. QUICKSTART.md showed
m0serve 0.10.0against a 0.11.0 tree. The doc promises every command in it is executed by CI, and that promise is kept forbash blocks — but the echo lives in atext block, whichrun_quickstart.pydisplays rather than asserts. That is the right design (the other text block interleaves output from three commands and is not byte-stable), so the check belongs incheck_docs.py, where prose facts with a machine source live. docs/RELEASING.md names the fourth bump site;poe check-docsfails on all four.
Fixed
-
--app-diris prepended tosys.path, not appended. It appended where gunicorn, uvicorn andrunserverallsys.path.insert(0, ...), so an application module could be shadowed by an installed package of the same name — and the shadowed application simply is not the one served, with nothing to see. The help text,cli.mojoandapp.mojohad all said "prepended" since the flag existed; now it is true.prepend_to_pathalso declines to move an entry already at the front, and leaves duplicates further down alone — a path the user put there is not the server's to edit. Found by dogfooding the wheel, reconfirmed by the three-project pass, and guarded by asmoke-servephase that puts a module nameddjangounder--app-dirin a venv where the real Django is installed. -
Every
Set-Cookiean application set lost itsexpiresandSameSiteattributes. The WSGI/ASGI bridge parsed eachSet-Cookieline into aCookieand re-serialised it, and that round trip was lossy four ways:Expirationis a stub whosefrom_stringparses nothing,SameSitematched only lowercase values, a value was cut at its first=(base64 pads with one), and any attribute the struct does not model was dropped. Django's session and CSRF cookies therefore reached every browser withoutexpiresorSameSite— a persistent cookie silently demoted to a session cookie, and a CSRF cookie without its defence. Application lines are now transmitted verbatim (ResponseCookieJar.add_raw), which is also the cheaper path on the measured response half of the bridge. Found by serving three real Django projects;smoke-djangonow reads the cookie off the wire and requires all four attributes, because curl's jar stores name and value only and could never have seen it. -
Uploads between ~1.5 MB and
--max-bodywere refused with400. The per-connection receive buffer had its own 2 MB ceiling that--max-bodynever raised, so a body the server advertised as acceptable was rejected by the wrong check with the wrong status — under the default 4 MB cap too. The limit is now derived (ServerConfig.recv_buffer_limit()= headers plus body allowance, floored byrecv_buffer_max), so raising the body cap raises the buffer with it. A 7.1 MB image upload to a real Django app found it. -
Concurrent ASGI streams truncated each other, and enough of them wedged the executor. The chunk credit window is per stream (64 KB) while the chunk channel is one shared
SOCK_DGRAMpair, so N streams over-commit it;send_stream_chunkthen dropped the datagram it could not place — a short body under a clean terminator, or, with the end frame dropped, a response that never completed at all. Twelve concurrent WhiteNoiseFileResponses under Django were enough. Now bounded globally (_ASGI_TOTAL_WINDOWin the shim, where waiting is anawaitrather than a Mojo spin that would hold the GIL against the very loop that has to drain the channel), with the loop keeping owed credit and retrying it when the ack channel is momentarily full.smoke-asgiruns 32 concurrentFileResponse-shaped streams and checks every byte. -
SIGTERMnever returned while a handler thread sat in a response that never ends. AStreamingHttpResponseserved under WSGI is buffered, so an SSE generator never returns and its pool thread never comes back;stop_and_joinwaited for it forever, turningdocker stopinto aSIGKILLafter the grace period. The join now has the same 5 s budget as the drain (ThreadSet.join_within), after which the process exits naming how many threads it left inside the application. Nothing in the process can unwind Python on another thread, so leaving is the only correct answer; waiting was not. -
smoke-wheelleaked a server on every run, and could pass against the wrong binary. Nine orphanedm0serveprocesses accumulated over one day of development, all stillLISTENing on port 8129, one of them still answering200 OKa day after the run that made it.Two independent defects. The launch was
(cd "$work/app" && env ... m0serve ...) &— a list, which bash cannot exec-optimize, so$!was the subshell rather than the server.kill $pidkilled the wrapper and leftm0serveorphaned to init. Addingexecmakes the subshell become the server, which is howbench_mixed_workload.shhad been doing it all along. And theEXITtrap only removed the temp directory, so everyfailafter the launch leaked one too; the server pid is in the trap now.The consequence was worse than untidiness. The server sets
SO_REUSEPORTbecause prefork needs it, so the kernel adds a new listener alongside a stale one and load-balances between them: a leaked server from an earlier run can answer this smoke's request, and the assertions then pass against a binary that is not the one under test.smoke-wheelnow refuses to start when 8129 already has a listener, naming the pids and the command to clear them.Verified by running the pre-fix task once (leaks exactly one process,
ppid 1) against the fixed one (leaks none, on both the success and the failure path), and by putting a decoy listener on 8129 to trip the new refusal. -
The README quoted a decomposition its own measurements had retired. It said the one-worker gap to Granian "splits evenly, 1.58x HTTP layer and 1.58x bridge" — numbers from before the CPU-normalized re-run, which WSGI_PERFORMANCE.md had already replaced with ~1.0x × 1.35x and explicitly marked as "records of what was measured, not descriptions of the present". The README kept quoting them, in raw rps, against a comparator since found to be running 1.75 cores. Rewritten from the artifact.
-
"There is no chunked encoding" was no longer true. The server has chunked transfer-encoding and ASGI responses stream through the executor chunk-framed on HTTP/1.1. WSGI responses are still fully buffered, but the reason is PEP 3333 — a WSGI response carries a
Content-Length, which means knowing the length — not a missing feature. -
The PyPI project page told aarch64 users their wheel did not exist. 0.11.0 shipped a
manylinux_2_35_aarch64wheel and its release notes claimed "the platform matrix on the README is the platform matrix on the index" — which was true of the repository's README and false ofpackaging/m0serve/README.md, thereadmenamed by the wheel's pyproject.toml and therefore the page PyPI renders. That one still readLinux aarch64 | buildable, not yet shippedfor the whole of the release. Two READMEs, one of them published, and the ratchet was pointed at the other. -
A README number quoted twice, guarded once. The mounted-isolation p99 (2.8 ms) now appears on the first screen as well as in the mounts section. It is not artifact-backed —
hybrid_isolation.pyasserts a deliberately generous ceiling rather than recording the figure — socheck_hybrid_p99_consistentchecks the two copies against each other instead. A number edited in one place and not the other is the ordinary way a README starts contradicting itself. -
The bench prose was answerable to nothing, and it was wrong.
render_bench_docskept the generated tables honest; the sentences around them — where the headline claims actually live — were checked by no one. docs/WSGI_PERFORMANCE.md stated the WSGI result as a decomposition, "roughly 1.0x HTTP layer × ~1.35x bridge", and it does not reconcile with the artifact directly beneath it: the measured per-core gap is 1.21x, and a 1.35x bridge term requires an HTTP layer term of 0.89x — this server's HTTP layer slower than the comparator's, which the same sentence denies. -
The boolean-flag dispatch had a fallthrough.
parse_argsended its chain withelse: opts.metrics = True, so a new flag added to_is_booland forgotten in the dispatch silently enabled Prometheus metrics instead of doing its job.--doctorwould have been the first victim. Theelsenow raises, andtest_cli.mojoasserts each boolean sets only itself.
Documentation
-
textshelfre-measured after stage 1 (REAL_APP_VALIDATION.md, Revisited). With--realtimeand the pool composing, the recommendation the record pointed at changed, so it was re-measured rather than re-reasoned. Two findings. m0serve's ASGI executor matches uvicorn and daphne to the millisecond on both a sync and an async generator — whatever streams under them streams under it. And the application's own AI streaming endpoints do not stream anywhere, including its production daphne: the producer is a sync generator, which Django's ASGI handler consumes before serving. That makes those endpoints free to move, which leaves the--mount-with---realtimerefusal as the only thing standing between a mixed application and one process — recorded in the ROADMAP as a re-ordering of stage 2, ahead of the WebSocket half. The real-application pass produced one finding about the shape of the server rather than a defect in it:--realtimerefuses--blocking-threads, so the cheapest way to hold a stream (M0-Hold: +2 MB per 200 held, no Python state, no database connection) costs the pool that cures the hostage pathology — measured on textshelf as a 1 543 ms fast-path p50 under--realtimeagainst 0.3 ms with the pool, with eight slow views in flight. The entry records the numbers, the mechanism the executor already uses to solve the identical problem (a reserved begin frame the loop's handler turns into a subscription), a staged design for SSE holds then sockets, the narrower--mountrefusal that follows from it, and what must be shown before it is built. Verdict recorded with it: the larger of the two is the difference between the realtime claim being demonstrable and deployable. -
Three real Django projects, served — the record (REAL_APP_VALIDATION.md). The plan that file used to hold has been executed:
transcripts(plain WSGI,src/layout),color-separation(numpy/Pillow pipelines, uploads, downloads) andtextshelf(four SSE endpoints, three pubsub modules, WhiteNoise, djstripe) served from clean clones against scratch databases, through--doctor, byte-parity againstrunserver, the feature matrix, the topology matrix, a realtime retrofit and a soak. Four defects, all fixed below, three of which no application inapps/could have shown. After the cookie fix, every remaining parity difference on every route of all three apps isconnection: keep-alive,x-thread, or Django's debug page echoing its own port. -
The desktop-Mac hypothesis, and the packaging tension under it (ROADMAP, Open questions). Recorded because the relevant decision is already shipped and otherwise invisible:
poe build-servepins--target-cputoapple-m1, the oldest Apple Silicon, so the PyPI wheel deliberately forfeits M-series-specific capability — including the +sme/+sme2 matrix extension the build comment notes this M4 would otherwise target. The pin exists because the first release crashed with SIGILL in a clean container, so it is not a mistake to undo; it is a tradeoff that points the other way from "exploit the Mac's silicon", and the two should be reconciled deliberately. Also recorded: what has to be established first, including that this toolchain has nogpumodule at all, and that the neural engine is a CoreML surface rather than something a language targets directly.
0.11.0 — 2026-08-26
The release that makes three published claims true at once: the quickstart
works against the PyPI package, pip install m0serve includes the publish
helper the realtime story depends on, and the platform matrix on the
README is the platform matrix on the index.
Added
-
m0serve.m0pubships in the wheel. The publish half of the realtime feature —m0pub.publish(channel, data)from any sync view, oneos.writeper worker plus an atomic fetch-add for the globally unique event id. It was previously only in the repository's demo app, so a pip user had a server that could hold connections and no way to publish to them. Pure stdlib; degrades to 0 workers under any other WSGI server and to unnumbered frames without the shared counter, exactly as documented. -
QUICKSTART.md, and it is executable. Ten minutes from
pip install m0serveto live multi-tab sync from one synchronous Django file — SSE verified by curl with expected output stated, WebSockets in the browser, cross-worker fan-out with--workers 2. CI extracts the fenced blocks and runs them against the tree's own wheel on every pull request (poe smoke-quickstart), so the doc a stranger follows is pinned, not aspirational. It caught its own author before anyone else: a first draft asserted event ids survive a server restart, and the runner failed the doc — ids are unique across one server's workers, by design. -
llms.txt— the operating contract for agents: strict flags, exit 78 refusals that explain themselves, theM0-Holdprotocol, where to start. -
Linux aarch64 wheels (Graviton, Ampere, arm64 Docker). The platform was already proven — built by hand in an arm64 container, passing the full wheel smoke including the removal sabotage — so the only thing between it and users was a CI runner.
build-wheelsandwheel-consume-linuxare now matrices over both Linux architectures, and the aarch64 wheel is consumed on real arm64 hardware rather than under emulation, which would defeat the purpose of a job that exists to run an artifact on a machine that did not build it.Also added to
test.yml, not just the release path:release.ymlruns on a tag, so aarch64-only would have meant discovering a break during a release, after the GitHub release exists and with the upload gated behind it. That is the failure shape the consume jobs were built to prevent.The README claimed Linux arm64 support before the wheel existed, so a Graviton user would have got
No matching distribution found— the literal "didn't install" comment the release checklist names as its first risk. -
scope["client"]andREMOTE_ADDR: the peer reaches Python. The fork'saccept()passed a 4-byteaddrlen, so the kernel truncated the peer address before the IP bytes — it was unreadable even in principle.accept_with_peerkeeps the full sockaddr, the event loop stamps each request (HTTPRequest.remote_addr/remote_port, captured once per connection on the provision), and the peer crosses to Python on both protocols: WSGI getsREMOTE_ADDR/REMOTE_PORTin the environ (per request, C-API only, same no-leak discipline), ASGI getsscope["client"] = (host, port)on http and websocket scopes. Django populatesrequest.METAfrom these only when present —client: Nonedoesn't error, it silently logs every visitor as address-less, which disables rate limits, IP allow-lists and audit logs. -
apps/django_asgi+poe smoke-django-asgi: Django's own ASGI handler, proven. A baredjasgidiscoversdjasgi.asgi:application, detection classifies it ASGI, and the executor serves it zero-config. The smoke pins: discovery + theasgi-loopbanner;/metashowing the real peer (verified load-bearing against a server sendingNone— it answers empty); four overlapping 400 ms async views completing in ~1x;StreamingHttpResponsestreaming live rather than buffered; a signed-cookie session counter surviving three round trips; SIGTERM.smoke-wsgigains the environ-sideREMOTE_ADDRassertion. -
Cross-worker fan-out for ASGI applications:
state["m0"]. Every ASGI app now finds a pub/sub object in its lifespan state — the Channels channel-layer shape with no Redis, riding theBroadcastBusthat already existed for GRIP.m0.publish(channel, payload)writes one datagram per worker channel (m0pub's exact protocol, shared-atomic event ids included, best-effort on a full or dead channel);m0.subscribe(channel)is an async iterator fed by frames the loop's handler forwards to each executor as tagged submit datagrams. The bus fd conflict that blocked this — the executor consumesbus_read_fdfor its ASGI chunk channel — is answered by a second registered fd on the loop (peer_bus_fd), same codec, same drain, same handler entry. The bus (plusSharedAtomicsids and env exports) is now created unconditionally pre-fork: an ASGI app cannot be detected until after the fork, and a single worker publishing to its own subscribers rides its own channel — there is no separate local-delivery path to keep in sync.poe smoke-asgi-fanoutpins the spread (6 streams over 2 workers), delivery of one publish to every stream on both workers, distinct cross-worker ids, supervisor SIGTERM with a live subscriber, and the single-worker case. -
An ASGI server validator — the
wsgiref.validatethat never got written. WSGI has a stdlib conformance checker and this repo runs it; ASGI has nothing standard (theasgireftesting helper plays the server rather than checking one, and uvicorn/hypercorn/daphne verify themselves bespoke).apps/asgi_bare/bareapp/validate.pyis the analog, written from the ASGI 3 spec: every required scope key with its exact type (bytes-vs-str is THE classic server bug), the receive stream's protocol,server/clienttuple shapes.M0_ASGI_VALIDATE=1wraps the app; violations raise and answer 500.smoke-asgigains the validated pass, with/validate/canaryproving the wrapper is engaged — a bogus message type the unvalidated server ignores (200) and the validator refuses (500), the/pep3333/canarypattern exactly. -
--mount PREFIX=SPEC: several applications in one process. Am0serveprocess can now host more than one application, routed by longest prefix before either sees the request —--mount /=djangoproj --mount /portal=portal.wsgi:appserves a Django project and a Flask app from one listener, one set of workers and one graceful shutdown. Each mount detects its own protocol (discovery included) and gets its own bridge and shim namespace; a path no mount claims is a 404 answered in Mojo, never entering Python; prefixes match on segment boundaries, so/appnever swallows/application.The prefix reaches both protocols through one seam, because they disagree about what it means: WSGI gets
SCRIPT_NAMEwithPATH_INFOtrimmed to the remainder, ASGI getsroot_pathwithpathleft whole (Django'sASGIHandlerstrips it itself). Getting that backwards leaves every direct request working while every generated URL breaks, sosmoke-hybridcompares Django'sreverse(), Flask'surl_for()and both frameworks'request.pathbyte for byte. New row:apps/hybrid_mix, deliberately two frameworks rather than two Django projects — those would sharedjango.conf.settingsand the first import would win, which would make the isolation claim a lie.Refused rather than guessed, each with a message saying why: mixed WSGI/ASGI mounts (routing them is done; giving each its native execution mode is the next stage),
--mountwith--realtime(an inbound WebSocket message has no defensible destination among several urlconfs), and a mounted server taking the asyncio executor (one submit channel cannot say which mount a job is for). See docs/WSGI_VS_ASGI.md §9. -
Mixed mounts, each in its native execution mode. A sync Django app and an async FastHTML app now run in ONE process, sharing one listener and one graceful shutdown, with Django's requests on handler-pool threads and FastHTML's on the asyncio executor. The mechanism is a submit lane per mount — the single submit channel became one
SOCK_DGRAMpair each — so the loop hands a job to the worker that can run it; oneProvisionPoolper loop stays, since a slot indexes that loop's provisions.match_path_prefixis now the single implementation of the matching rule, so the lane a job takes and the application the handler picks cannot disagree, and each worker builds only its own mount's application rather than every mount's.Measured and smoke-pinned: with four blocking 2-second Django views holding every pool thread, the FastHTML mount answers at p50 1.3 ms / p99 2.8 ms.
apps/hybrid_mixis now Django + Flask + FastHTML in one process. -
Several ASGI mounts, one executor each. The one-ASGI-mount limit is lifted: every ASGI mount gets its own executor thread, its own bridge and lifespan, and its own drain-ack pair (
OffloadPool.enable_stream_ack), with the loop routing each ack by the lane recorded at submit (slot_lane) — credit belongs to the executor that owns the slot, and an ack routed anywhere else is a stream stalled forever rather than an error, which is why the smoke streams 256 KB (four credit windows) from two executors concurrently and byte-counts both. Executors share the one chunk channel: its datagrams were always slot-addressed, and a singleSOCK_DGRAMqueue is globally FIFO across writers, so the recycled-slot safety argument survives. The reserved channel names now carry the executor's lane (\x01<kind>/<slot>/<lane>; the unmounted wire format is unchanged), which is how disconnect tags and inbound WebSocket messages route back to the owning executor — parsed from the slot's own subscription record, no side table to drift. Shutdown sends one pill per executor on its own lane.apps/hybrid_mixgainsfeed.asgi, a second async mount beside FastHTML's.
0.10.0 — 2026-08-25
Promotes 0.10.0rc1 unchanged. The rc's whole purpose was to run the upload path once on a filename that could be spent: it published, installed from the real index on machines that never built it, and served. Nothing needed fixing afterwards, so this is the same artifact under a stable number.
What the release candidate cost, kept here because the reasoning outlives the incident: three attempts and four defects, every one of them invisible from the machine that built the artifact.
- The binaries were compiled for the build host's CPU (
mojo builddefaults--target-cputo it), som0serve --versiondied with SIGILL in a clean container after passing on the runner that produced it. Not detectable by static inspection at all — only by running the artifact on different silicon. wheel-inspectruns on Linux and checks both wheels, but read Mach-O throughotool, which macOS has and Linux does not.- The glibc negative control ran
pipwith no shell in the container, so its glob stayed literal and pip refused the wheel for the wrong reason. The guard caught precisely that and declined to score it as a pass. - The release published as "Latest", above the current stable, because
gh release createdoes not infer pre-release status from a tag.
Changed
- Version only. No source changes from 0.10.0rc1.
[0.10.0rc1] — 2026-08-25
First release published to PyPI, as a release candidate: it claims the name and exercises the production upload path — trusted publishing, the two-platform wheel set, the tag/version cross-check — before a stable number is spent on an untried path.
One correction, because this entry originally claimed otherwise: pip install m0serve does install it. pip excludes pre-releases only when a
stable version also exists, and there is none here, so the rc is the only
candidate and pip takes it. The rc therefore buys rehearsal, not
invisibility; quietness rests on nothing being announced. The upside is that
the index-install path — pip choosing the right file from several platform
wheels, from a real index, on a machine that never built them — is proven
rather than deferred.
Added
-
pip install m0serve— the server as an installable binary. A WSGI/ASGI server for Python applications, with no Mojo toolchain on the target machine and nothing fetched at install time (the wheel declares no dependencies).m0serve myproject.wsgi:applicationserves either protocol, detected from the object.One wheel per platform covers every supported CPython — 3.10 through 3.14 including free-threaded builds — because
m0servedoes not link libpython; Mojodlopens the interpreter at run time, so there is no CPython ABI in the archive to be compatible with and no CPython in it to redistribute. Verified across all five, and by serving Django 5.2.17 on CPython 3.11 from a wheel built on 3.13 with Django 6.1.Built from
packaging/m0serve/, a separate project holding the only[build-system]in the repository: one in the root would make uv treat the repo as installable, and that build needsbin/m0serve, which needs the.venvuv is creating. -
The platform tag is measured, not declared (
scripts/wheel_tag.py). It readsLC_BUILD_VERSIONand versioned glibc symbols out of the staged binaries and takes the strictest floor. Copying the toolchain's ownmacosx_13_0tag would have shipped a wheel requiring macOS 26. -
poe bundle-serveandpoe check-serve-portable, mirroring thelibm0corepair, plusstage-wheel/build-wheel/smoke-wheel. -
Clean-consumer release jobs. The wheel is installed and made to serve in containers and on a runner that never built it — four CPython minors,
--network none, and a permanent negative control asserting an older glibc is refused by pip rather than crashed at startup. Each job asserts its own cleanliness first, andcheck-docsfails the build if one ever acquires a checkout.
Fixed
-
Binaries were compiled for the machine that built them.
mojo builddefaults--target-cputo the host CPU, so every artifact this project has produced was effectively-march=native. The first release run proved the consequence:m0serve --versiondied withIllegal instruction (core dumped)in a clean container after passing on the runner that built it, and on a developer machine the effective target wasapple-m4with+sme/+sme2— Scalable Matrix Extension, which no M1, M2 or M3 has.build-ffiandbuild-servenow pin the oldest CPU each platform must support (apple-m1,x86-64-v2,generic), andcheck-docsasserts they do. Unlike the rpath defect this resembles, it is invisible to static inspection — only running the artifact on different silicon can find it, which is exactly what the clean-consumer jobs do. -
The portability checker could not see an executable, and the bundler was blind the same way.
otool -Lprints a dylib's ownLC_ID_DYLIBbefore its dependencies; anMH_EXECUTEhas none, so dropping the first entry discardedbin/m0serve's only real dependency. The checker reportedSELF-CONTAINEDfor a binary that resolved the Mojo runtime through a developer's.venv, andbundle_ffi.pywould have copied zero runtime libraries and called the bundle complete. The parse now lives once inscripts/binfmt.py, keyed on the load command's presence, self-tested in CI against both cases. -
build-servehad no post-link surgery at all, so everybin/m0serveever built recorded a search path into the venv that produced it. It now sharesbuild-ffi's, viascripts/relocate.py, and completes its bundle in place — sobin/is the shipped shape rather than a development arrangement that worked for a different reason. -
The macOS deployment target is pinned to 13.0.
mojo buildhonoursMACOSX_DEPLOYMENT_TARGET; without it the binary inherited the build host's SDK, making the wheel's reach a property of whichever image GitHub callsmacos-latest. -
"The Linux artifact is statically linked" was true of one file only. Measured on
libm0core.soand carried as a platform fact; them0serveexecutable links the three Mojo runtime.sofiles on Linux exactly as on macOS. Nothing in the tooling decides by platform now, only by what the file records.patchelfis a Linux build requirement in consequence, andnightly-canary.yml— which buildsm0serve— had no dependency step at all.
Changed
-
NOTICE describes artifacts, not just the source tree. The wheel is the first artifact to redistribute the lightbug_http fork in object form, and MIT requires its notice to travel with copies — so
licenses/LICENSE.lightbug_http.txtandlicenses/NOTICE.m0serve.txtship inside the wheel and them0servebundle. NOTICE gains a table of which notice covers which artifact, and no longer implies the Linux builds contain no Modular code. -
The README's platform claim is architecture-qualified, because once wheels exist
pipenforces it: macOS Intel is not untested but impossible (no toolchain wheel), Linux aarch64 is buildable and unshipped, and the glibc floor excludes musl. The free-threading claim moved from "3.13t+" to 3.14t, which is what is actually tested — 3.13t systematically immortalizes objects, aspyproject.tomlanddocs/WSGI_VS_ASGI.mdalready recorded.
0.9.0 — 2026-08-24
Added
-
ASGI WebSockets (Phase 3b):
app.wsworks. A WebSocket handshake on an ASGI app gets awebsocketscope on the executor's loop. The ready 101 (built by the loop's own validator from the original request's key) is held until the application'swebsocket.accept— the same approve/perform split as M0-Hold — and released through the completion channel behind a FIFO-anchoring begin frame; outbound frames are RFC 6455-encoded executor-side and ride the 3a chunk channel into thesocketsregistry;websocket.closequeues the close frame plus the end marker and the loop closes after both land; inbound messages travel as tagged submit-channel datagrams into per-slot queues behindreceive(); a disconnect cancels the task and a never-answered handshake resolves as 403 so no slot leaks.smoke-asgidrives a raw RFC 6455 probe (verified accept, text and binary echo, close(1000) to the FIN, abrupt-vanish cleanup);smoke-fasthtmlproves FastHTML'sapp.wsend to end — its full surface (pages, SSEEventStream, WebSockets) now runs onm0servewith zero configuration. -
ASGI streaming responses (Phase 3a): SSE actually streams. FastHTML's
EventStream, Starlette'sStreamingResponse, and Datastar patch streams now stream live through the asyncio executor instead of meeting the buffered watchdog's 500. Response chunks travel from the executor thread to the event loop as datagrams on a private per-loop channel and ride the existingSSERegistryper-slot outboxes under reserved channel names (a leading control byte no HTTP header value can carry, so GRIP channels cannot collide). Correctness is ordering and credit, both smoke-pinned: a stream's begin frame precedes its head on one FIFO channel (so a recycled slot can never receive another stream's chunks), a 64 KB credit window with 32 KB chunk split means the loop's drain acks pace the producer (a 100 MB stream behind a slow reader grows server RSS ~2 MB), bodies are close-delimited and the loop closes on end-of-stream via the previously-uncalledsse_is_streaminghook, client disconnects cancel the app task (uvicorn's contract), and comment heartbeats are suppressed on ASGI streams so an SSE event split across chunks can never be corrupted — asserted byte-exact under a 300 ms cadence, alongside an md5-checked 1 MB streamed body.smoke-fasthtmlnow asserts live/sseticks; the buffered escape hatch (--blocking-threads+ ASGI) keeps its watchdog refusal. WebSocket scopes are Phase 3b. -
The asyncio executor: real await-concurrency for ASGI (Phase 2). Zero-config ASGI now serves through one executor thread per event loop (
m0_wsgi.asgi_executor) running the bridge's persistent asyncio loop: the loop parks each request and submits its slot through the unchangedOffloadPooldatagram channel,loop.add_readerturns slots into tasks, and completions answer throughput_response/complete. Requests overlap wherever the application awaits — eight concurrent 1.5 s awaits complete in 1.51 s on one loop with zero threads — and the banner saysasgi-loop. The executor path crosses method, path, query, headers (ready lowercase byte-pairs), and body straight through the C API as stolen tuple slots — no environ, no CGI names, no Python-side re-transform — and the RSS guard stays flat (20 KB–1.7 MB across runs, allocator noise against the 12 MB limit). Exactly one lifespan runs per loop (fallback handlers are built withlifespan=False); the executor picks uvloop for its own loop where installed. An explicit--blocking-threads Nkeeps the Phase-1 buffered pool as the escape hatch;--threads Ncomposes (one executor per loop, free-threaded CPython only, as before).poe bench-asgiis the standing gate against uvicorn: the mixed slow/fast fast-request p99 passes (2.87 ms vs 3.27 ms); hello-world throughput stands at 0.88–0.94x with the remainder located and its fix paths recorded in docs/WSGI_PERFORMANCE.md §"The ASGI executor vs uvicorn". -
m0serveis now a hybrid WSGI/ASGI gateway with zero-config detection. The protocol is detected from the application object at load (coroutine-function duck typing — the rule uvicorn and asgiref share;--protocol auto|wsgi|asgioverrides it), so FastHTML, Starlette, FastAPI, and Django'sasgi.pyserve from the same binary that serves Django/Flask WSGI. ASGI requests run on a persistent per-bridge asyncio loop, buffered: the scope is derived in the shim from the same C-API environ,send()events collect into the same(status, headers, body)tuple, and no new per-request Mojo↔Python object traffic exists —smoke-asgi's RSS guard (same 12 MB/10k-request limit as the Django row) measured 356 KB on day one. Lifespan startup/shutdown run with uvicorn's "auto" semantics, and lifespanstatereaches request scopes. Streaming responses are the recorded limit: a response still unfinished 10 s after its firstmore_body=Trueis answered with an explanatory 500 pointing at docs/WSGI_VS_ASGI.md §8 (the buffered bridge cannot carry an infinite SSE/EventStream; that surface is the design's Phase 3). -
Spec discovery: a bare
m0serve MODULEnow triesMODULE:application,MODULE.asgi:application,MODULE.wsgi:application,MODULE:app,MODULE.main:appin order — a Django project or a FastHTML/FastAPImain.pyserves without learning either convention. An explicit:ATTRnever falls back. A total miss lists every spec tried, and a non-callable names both expected signatures. -
Zero-config concurrency: with no
--workers/--threads/--blocking-threadsflag and noM0_*topology variable,m0servenow starts a handler pool ofmin(cores, 8)blocking threads, so one slow view no longer stalls every connection out of the box. Any explicit topology value wins —M0_WORKERS=1orM0_BLOCKING_THREADS=0restore the old single-loop shape — and--realtimekeeps the single loop (its streaming hooks run on the loop's handler). The banner reportsprotocol=and marks the pool(auto). -
New rows and gates:
apps/asgi_bare(the ASGI sibling ofwsgi_bare— every route pins one clause of the contract) withpoe smoke-asgi, andapps/fasthtml_demowithpoe smoke-fasthtml(skips where python-fasthtml is absent).python-fasthtmljoined the dev dependency group.
Changed
wsgi.multithreadisTrueunder the zero-config pool (it is a real thread pool), and ASGI apps refuse--realtimewith an explanatory message — the M0-Hold contract is a WSGI response-header protocol.- docs/WSGI_VS_ASGI.md gained §8: the deliberate revisit of its §6 verdict, with the three-phase gateway design (buffered bridge → per-loop asyncio executor over the offload channel → ASGI realtime over the existing bus/registry transport).
0.8.0 — 2026-08-24
Added
-
poe bundle-ffi— a self-contained, redistributablelibm0core. The macOS artifact resolves the Mojo runtime at load time and is useless without it, so releases now ship<asset>.tar.gzcontaining the library, the runtime it loads, and both licences — 1.65 MB, verified todlopenfrom an unrelated directory withDYLD_LIBRARY_PATHandDYLD_FALLBACK_LIBRARY_PATHunset. The bare library remains a separate asset so existing download URLs keep working; the Linux.sois statically linked and self-contained already.The dependency closure is discovered, not listed — a hand-written first attempt shipped only the library named in the error message and then failed on its dependency, so
bundle_ffi.pywalks the graph and a toolchain bump that adds a fourth library is picked up rather than silently producing a broken bundle. The task refuses to finish unless the result is self-contained, so assembly and assertion cannot drift apart, and CI runs it on every commit: a release can no longer publish an asset that only loads on the build machine.The Mojo runtime is Apache 2.0 with LLVM Exceptions. Rather than rely on the exception excusing attribution for separately shipped files, the bundle complies with section 4 in full — licence text, attribution, and an explicit 4(b) notice that install name, rpath and code signature were changed while the executable code is byte-for-byte as built. See
NOTICEand docs/FFI_DISTRIBUTION.md, which also record the one piece of contrary evidence: themojo_compilerwheel still declares the proprietary MAX licence, though it contains only the compiler and runtime — no MAX components — and ships no licence file of its own.
Changed
-
The WSGI response path was never measured, and was 10x the request path.
build_responsenow costs 3.30 µs instead of 22.97 for a six-header Django-shaped response;serve()25.51 → 5.65 µs. End to end onapps/wsgi_bare— one response header, the least favourable shape — 49,517 → 56,896 rps (+14.5%), p50 291 → 252 µs.The cause was not the Python boundary. Splitting it put the
PythonObjectheader read at 1.27 µs (5%) andname.lower()at 19.36 µs (84%) — a Unicode-lowercased copy of every header name, allocated solely to compare against one constant.name_iswas already in the repo doing this correctly for the identical Set-Cookie dispatch on the request side, and its docstring already named the mistake. The fix is that one call.scripts/bench_bridge_parts.mojonow covers both directions, at one, six, and six-plus-two-cookie response headers, so this cannot go unpriced again.name_isandascii_lower_bytegained unit tests (test_headers.mojo) — they had none despite being the whole of header case folding, now in both directions; the boundary test was verified to fail when theA–Zrange is widened by one byte. -
The Granian layer split is re-measured on 3.14.7t, and the gap it was written to explain is spent. After five bridge changes, the row that has driven every roadmap priority since it was taken: at four workers m0serve is now ahead of Granian, 101,892 rps against 98,489; at one worker the gap is 2.50x, down from 4.31x.
The re-measurement carries its own validity check. Nothing here touched the HTTP layer or Granian, and all three rows that should not have moved reproduced within 2% —
apps/hello0.99x, Granian 0.98x at one worker and 0.99x at four — across five weeks and a Granian bump to 2.8.2. The two rows that moved are the two the bridge work touched: 3.94x at one worker, 2.91x at four.What is left at one worker is 1.58x HTTP layer × 1.58x bridge — dead even, and their product is the whole 2.50x. The original conclusion, "the headroom is in the bridge, not the HTTP layer", was right when measured (6.30x against 1.59x) and no longer is. Part of the four-worker result is Granian's own 19% loss from one worker to four on a four-performance-core box; m0serve scales 2.08x over the same step. Both stated.
-
m0-sqlite: the text-scan cost is measured, and the zero-allocation read is documented.
bench_sqlite.mojogained TEXT rows — the one column type it never priced — showingcolumn_textpays 2.1x for its per-rowStringat 64 B and at 4 KB alike. The fast path already existed:column_blob_intoworks on TEXT columns (SQLite's UTF-8 TEXT→blob conversion is a pointer handoff), and the two docstrings now point at each other. No new API, per the package's own rule. Also recorded in SQLITE_PERFORMANCE.md: the WSGI-bridge techniques checked item-by-item against this package — most already applied or without an analog, and the one fresh suspect (a hiddenStringLiteral→Stringconversion on every binder's happy path) measured at 0.0 ns and was left alone. -
Each request's environ starts as
PyDict_Copyof a finished base template —build_environ1.78 → 1.56 µs, the bridge 2.35 µs. One C call replaces ten per-request hash-and-stores (58 ns vs 214 measured), andPython().cpython()is acquired once per request instead of sixteen times. The template is copy-isolated: an app may overwrite or delete anything in its environ without touching the next request's, probed with ten vandal/inspect cycles and a secondset_base. The decision that didn't ship matters as much: an intern cache for recurring header names/values measured as a net loss — its hit-path byte-compares cost more than the 15 ns decodes they would skip — so it was never built, and the bridge is now near the structural floor WSGI's environ shape sets. -
The response body is read through
PyBytes_AsStringinstead ofctypes— the bridge costs 2.50 µs per request instead of 3.52.body_byteswas 31% of what the bridge had left, and the split named one cause: the shim'sbody_addr(), which built twoctypesobjects per request. It now runs no Python at all —PyObject_Lengthfor the length,PyBytes_AsStringfor the address, onememcpyfor the copy. 1.07 µs → 0.13 µs, and end to end on one worker servingapps/wsgi_bare: 45,891 → 48,852 rps, +6.7%, p50 315 → 295 µs. That is 1.69x cumulative against the 28,853 rps measured before any of the bridge work.The general finding matters more than the optimisation.
Python().cpython()binds noPyBytes_*and noPyUnicode_DecodeLatin1, andexternal_callcannot reach them either, because libpython is not on the link line — Mojodlopens it, which is whyCPythonis a struct of loaded function pointers. But that struct exposes its handle, and the stdlib's ownExternalFunction[name, type].load(cpy.lib.borrow())opens the functions it omitted. The whole CPython C API is reachable, which retires "there is no binding for it" as a constraint on this boundary.PyBytes_AsStringis stable-ABI and checked — NULL plusTypeErroron a non-bytes, where thePyBytes_AS_STRINGmacro would read wrong offsets and is not a symbol anyway. The pointer is resolved once at construction; the call it returns is 1.0 ns.smoke-django's RSS guard still reports 0 KB over 10k requests — reading through a raw pointer takes no reference.The request body followed in the next entry — see below.
-
The request body crosses as a real
bytesviaPyBytes_FromStringAndSize— the blob design is fully retired. Mojo builds thebytesstraight from the request's own buffer (one copy, inside the call) and hands it to the shim as a stolen tuple slot;io.BytesIO(bytes)shares the buffer copy-on-write, sowsgi.inputcosts no second copy where the old bytearray protocol always paid one. Staging a 1 KB body: 1.6 µs → 0.07 µs (~23x); end to end, a 1 KB POST toapps/wsgi_bare's/input/read: 42.1k → 47.3k rps (+12%), GETs unchanged. Deleted outright: the shim's 64 KB transfer bytearray,buf_addr(), the grow protocol, and the shim'sctypesandsysimports — it now imports nothing butio. Every request costs exactly one call into Python, thePyObject_CallObjectthat runsrun(environ, body).
Fixed
-
The C-ABI artifact no longer records the machine that built it.
build-ffirewrites both paths after the link — install namepackages/m0-core/libm0core.dylib→@rpath/libm0core.dylib, search path/Users/runner/work/.../modular/lib→@loader_path— taking the macOS artifact from BROKEN to SATISFIABLE: it now looks beside itself, so a consumer can supply the runtime. Neither path can be suppressed with a linker flag, becausemojo buildadds them itself; macOS also needs an ad-hoccodesign, since arm64 invalidates the signature on any Mach-O edit and an unsigned dylib will not load at all.smoke-ffiwas silently undoing this: it ran its ownmojo buildinto the same output path, so it overwrotebuild-ffi's output and tested an unfixed artifact. It now depends onbuild-ffiand tests what that task emits, supplying the runtime throughDYLD_LIBRARY_PATH— the documented consumer requirement, exercised rather than accidentally bypassed.Linux was never broken the way macOS is. The published
.sohas noDT_NEEDEDentries at all and is statically linked; itsDT_RUNPATHwas inert debris. The missing-runtime problem is macOS-only, andcheck-ffi-portablenow reports three states —BROKEN,SATISFIABLE,SELF-CONTAINED— because pass/fail could not express that. It also reads ELF itself rather than shelling out: the previous version usedllvm-objdump, which exists on macOS, formats ELF differently, matched nothing, and reported a Linux artifact as portable. A guard that answers "fine" when it cannot read the file is worse than no guard.Still not self-contained: shipping the runtime turns on a licensing question, and the 2026-08-23 nightly still declares the proprietary MAX Platform license — five days after the Apache-2.0 relicensing, so the discrepancy is not a same-day packaging slip. Building the runtime from the Apache-licensed sources was assessed and rejected as disproportionate (an MLIR/Bazel compiler stack for a 1.57 MB macOS-only bundle). See docs/FFI_DISTRIBUTION.md.
-
The published
libm0coreartifacts do not load off the machine that built them, and now there is a check that says so. Every release from v0.1.0 has shipped a C-ABI library whose recorded search path is the CI runner's own directory, sodlopen— the entire point of the artifact — fails for anyone who downloads it.smoke-fficould never catch this: it loads the library in the build tree, where the venv it was linked against still exists, so it passes on exactly the machine where the defect cannot appear.poe check-ffi-portablechecks what a load attempt cannot — that every recorded search path is self-relative and every dependency is a system library or shipped alongside — and fails on the current artifact and on every published one, which is how it was verified. The README no longer claims the prebuilt artifacts are usable as-is.The fix is demonstrated in docs/FFI_DISTRIBUTION.md (the bundled artifact loads from an unrelated directory with a clean environment) but not yet applied: two of the three defects are ours and need no permission, while shipping the Mojo runtime's 1.57 MB three-file closure turns on a licensing question that is genuinely unresolved — Mojo's sources went Apache-2.0 with LLVM Exceptions on 2026-08-18, but the wheel shipping those prebuilt binaries still declares the proprietary MAX Platform license. Recorded in NOTICE.
-
body_bytesno longer swallows a pending exception.PyObject_Lengthanswers -1 with the exception set; folding that into the empty-body case returned an empty list and left the error pending, poisoning whatever C-API call ran next. Unreachable through the shim contract today (_bodyis alwaysbytes), found by review, fixed before it could become reachable.
0.7.0 — 2026-08-23
Added
-
m0serve --blocking-threads N/M0_BLOCKING_THREADS— Stage B, the acceptor and its handler pool. The event loop stops callingHTTPService.func. It parses the request, hands it to one of N handler threads, and returns towait(); the thread calls the handler and pokes the loop back, which encodes and writes the response through the sameRESPONDINGpath every other response takes. One slow view no longer stalls the connections pinned behind it: onapps/wsgi_bare, a fast request answered in 1 ms with two 1.5 s views in flight, against 2.7 s for the identical server without the flag — the same code, the same load, the flag as the only variable.This is the failure the mixed-workload benchmark measured and Stage A did not touch (fast-request p99 1.6 ms → ~194 ms, ~120x, under
--workersand--threadsalike), because a keep-alive connection belongs to the loop that accepted it in both modes and adding loops does not change that. Granian's--blocking-threadsis the same architecture and the reason the design had a working reference.Composes with both execution modes —
--workers Wgives W processes of N handler threads,--threads Tgives T loops of N each, one pool per loop because a job names a slot and a slot means nothing outside the loop whose provision pool it indexes. Off by default: it costs N threads and N handlers' worth of per-thread state per loop, and a server whose views are all fast gains nothing.Unlike
--threads, it is not refused on a GIL-enabled interpreter. A pool under the GIL is what gunicorn's--threadsis: CPU-bound views serialize, but a view waiting on a database, a socket or a sleep releases the GIL and the isolation is real — which is the workload the mode exists for. It is refused together with--realtime, because the streaming hooks (sse_drain_slot,sse_slot_disconnected,ws_message) are called on the loop's handler whilefuncwould run against a pool thread's own registries; half-wiring that would fail quietly. -
lightbug_http.offload— the queue itself, and it knows nothing about Python. TwoSOCK_DGRAMsocketpairs (submit, and a completion channel the loop registers exactly as it registers aBroadcastBuschannel) plus per-slot request/response storage. Datagrams because they preserve message boundaries: N threads receiving on one channel each dequeue one whole job, so the kernel is the queue and there is no mutex to write. Each handoff is published by the socketpair syscall that names it, which is the whole memory-ordering argument.m0_wsgi.blocking_poolis the thread side — the only half that attaches to an interpreter, which is what keeps libpython off everything else's link line.Retiring the pool is one method,
BlockingPool.stop_and_join, and that is a safety property rather than tidiness.next_jobblocks with no timeout, so the poison-pill count must equal the thread count exactly: a thread that receives no pill blocks forever, which is a hungpthread_join. Closing the queue does not rescue it — on Linux, closing the write end of a connectedAF_UNIXSOCK_DGRAMpair does not wake a peer already blocked inrecv, while macOS returns 0 and looks fine. That asymmetry cost a 20-minute CI timeout:test_offload.mojoread one pill more thanstophad sent, to "prove" the close was a backstop, and passed locally while hanging ubuntu. The claim is gone from the code and the count is now a property of the type instead of an agreement between call sites.Three things a slot in flight is not: touched by the loop, swept by the idle or header timeout, or recycled. A client that disconnects mid-job detaches the fd but leaves the provision borrowed until the completion arrives, so a late completion has nowhere wrong to land — a generation counter would detect that race, holding the slot removes it. Past 256 jobs in flight the loop runs requests inline rather than queueing them, which is a bound on what the channels must hold and degrades to exactly the behaviour of a server without the flag.
-
c/socketpair.mojo, extracted frombroadcast.mojo— one binding, two callers, and one deprecated-allocwarning site instead of two. See NOTICE. -
poe smoke-blocking-threads. Phase 1 runs everywhere: the--realtimerefusal, the isolation measurement, four clients abandoned mid-job leaving every slot recovered, HEAD through the pool (the loop has to remember it — by completion time the request belongs to another thread), a raising handler, and SIGTERM answering an in-flight request rather than dropping it. Phase 2 needs the GIL off and ispy-canary's new phase F: two loops of four handler threads each, proving Stage B composes with Stage A — and deliberately loading only half a pool, because past saturation a request queues for a thread, which is what a thread pool is and not what the row asserts. Measured at 1 ms against a 400 ms gate, so the row fails on a broken pool rather than on a busy machine.
Changed
-
The WSGI environ is built in Mojo through the CPython C API — the bridge costs 3.5 µs/request instead of 14.9.
PyDict_NewandPyDict_SetItembuild the dict,PyUnicode_DecodeUTF8builds every key and value, andPyTuple_New/PyTuple_SetItem/PyObject_CallObjecthand the finished dict to the shim. End to end on one worker servingapps/wsgi_barewith a browser-shaped keep-alive request: 28,853 → 45,715 rps, 1.57x, p50 508 → 315 µs, p99 1.06 ms → 681 µs.This retires the last large measured item in the Granian gap. The shim used to rebuild the environ in pure Python on every request, parsing a binary blob Mojo had just written — 28
_read_strcalls for a twelve-header request, 12.09 µs of the bridge's 14.23, 85% of it.The blob existed because Mojo 1.0's
PythonObjectleaks a reference per call argument, so the environ could not be passed as one. The raw C API refcounts explicitly and is not that path, which is what made this possible at all.smoke-django's RSS guard — the instrument for any change to this boundary — still reports 0 KB over 10k requests.The request body still crosses as bytes through the persistent bytearray, because Mojo 1.0 has no
PyBytes_*binding of any kind and abytesobject therefore cannot be built from Mojo. A request with no body now skips that path entirely —buf_addr()is not called and nothing is copied. There is noPyUnicode_DecodeLatin1either, so PEP 3333's latin-1 tunneling is spelled as a UTF-8 encode inenviron.mojoand decoded byPyUnicode_DecodeUTF8into exactly the samestr; ASCII, the overwhelming case, is its own UTF-8 and costs no copy.
Removed
serialize_requestand the request blob format. Nothing crosses the boundary positionally any more except the request body, which needs no framing because its length is passed as an argument.environ.mojokeeps the pure half —cgi_header_nameand the CGI/latin-1 byte transforms — so the mapping stays testable without an interpreter, andtest_environ.mojostill asserts the two statements of the CGI rule agree on every shape it distinguishes.
Fixed
-
Graceful shutdown no longer waits the full 5 s drain for connections that are already finished.
active_countcounts a connection that is merely open the same as one with a request in flight, so a server holding idle keep-alive connections waited out the wholeDRAIN_TIMEOUT_NSbudget at SIGTERM — 5.02 s to exit against 0.02 s idle, in every execution mode, which is most of whatdocker stopallows before it escalates to SIGKILL.The shutdown path now closes slots in
READING_HEADERSwhose receive buffer is empty — "between requests" — before it starts the drain clock. Those connections could never have been served by the drain loop anyway: it dispatchesEVFILT_WRITEonly, so a request arriving mid-drain is not read there. 5.02 s → 0.03 s under--workers 4, under--blocking-threads 4, and on a single loop.A slot mid-request, mid-response, or with a job in a pool thread is left alone, and the SSE/WebSocket farewell still runs first, so streaming clients get their close comment or 1001 frame. Both halves of the contract are pinned:
smoke-blocking-threadsalready asserted that a request in flight at SIGTERM is answered rather than dropped, andsmoke-shutdowngained a phase (scripts/drain_idle_probe.py) asserting idle keep-alive connections do not hold the drain open — checked against the unfixed loop, where it fails at 5.01 s.This also retires the standing suspicion that
--threadsshuts down slowly. It does not, and neither does the pool: every mode exited at 5.02 s, and a 5 s wait loses that race.
0.6.0 — 2026-08-23
The release that finished moving the WSGI examples off their own
server.mojo, and made the bridge 2.35x faster. --realtime and
--health-path carry the hold machinery apps/django_realtime used to own,
--reload re-forks workers onto changed Python in ~300 ms, and
serialize_request — 77% of the bridge's per-request cost — went from 48 µs
to 0.44 µs, taking apps/wsgi_bare from 12,289 to 28,911 rps. The
benchmarking also settled what comes next: one slow view raises fast-request
p99 by ~120x in both execution modes, which is the failure Stage B was
built to remove.
Added
m0serve --realtime— the hold machinery behind a flag.apps/django_realtimewas the last example carrying its ownserver.mojo; everything in it now lives inWSGIHandler. The flag turns on twoSSERegistrys (streams and sockets, holding disjoint slots),take_holdon every application response, the WebSocket handshake a buffered WSGI response cannot produce,ws_message_requestfor inbound frames, and — created before the fork and before the first Python call — theBroadcastBusand theSharedAtomicsid slot with theirM0_BUS_WRITE_FDS/M0_SHARED_ID_ADDRexports.M0_CORE_LIBis discovered rather than demanded: beside the binary first, thenpoe build-ffi's output, and left alone if already set. Off by default, because it costs two slot arrays and because it makesM0-Holda header the server consumes rather than one an application may emit for its own reasons.--realtimeworks under--threads N. The bus is built on the main thread before spawning and loopidrainsread_fd(i), exactly as workeridoes. ASOCK_DGRAMsocketpair does not care whether the peer is a process or a thread, som0pub.pyandsse_peer_frameare unchanged — the publisher reaches N threads with the Nos.writes it used to reach N processes and never learns which it is talking to.smoke-django-realtimephase 4 pins it where the GIL is off (py-canaryC3): six streams spread over four loops, one publish from one thread's Django reaching all six with numbered ids, then a clean four-loop drain on SIGTERM.m0serve --reload [--reload-dir DIR]— hot reload. A changed.pyunder a watched directory stops the workers and forks replacements onto the new module, in ~300 ms plus a drain. The flag forces a supervisor even at one worker and even under--threads N, and that composes with both execution modes for one reason: the supervisor never touches Python. It watches withlistdirandstat— libc, and therefore safe in a process forked withoutexec— and the fork still precedes the first Python call because the supervisor never makes one. A reload is a graceful shutdown followed by a fork: workers leave through the existingSIGTERM→ drain →exit_worker()path, unchanged, and the exits are accounted as a reload rather than a retirement, so the crash-respawn budget is untouched. Stragglers past a 5 s drain deadline getSIGKILL. What reloads is the worker; the Mojo binary is never re-exec'd, so a changed.mojostill needs a rebuild.--reloadsetsPYTHONDONTWRITEBYTECODE=1, which is not a tidiness choice. CPython validates a cached.pycagainst its source's mtime in whole seconds and its size, so a rewrite landing in the same second at the same length looks unchanged to the import system. The reloader sees it — it compares nanoseconds — re-forks, and the fresh worker imports the old bytecode: a reload that visibly happened and changed nothing. Writing no bytecode means there is never a cache to go stale.smoke-reloadpins it by editing same-length versions in the same second, and asserts no__pycache__appears.- The
wrkkeep-alive tail row, and the Stage B go/no-go (docs/WSGI_PERFORMANCE.md,scripts/bench_wsgi_tail.sh+scripts/bench_wsgi_tail_ka.sh). Three rounds on 3.14.7t: keep-alive p99 is 1.6–2.9 ms across--workersand--threadsat both 2 and 4, so the 84 ms tail does not reproduce as a property of the design; the single excursion in seventeen valid rows was in prefork. Stage B is a no-go on this evidence, and the gate is restated as a mixed-workload run, because a hello-route benchmark cannot exercise the slow-view isolation Stage B is half about. Granian 2.8.1 measured at 1.4–2.0x either mode on the same interpreter with a byte-identical response — recorded as the better-evidenced target. Also recorded: the ephemeral-port exhaustion that made the firstwrktable report a spurious 8–10x threads-vs-prefork tail gap and five empty rows, and why gunicorn cannot appear in a keep-alive table at all. scheme_separator(lightbug_http/uri.mojo, so also in NOTICE). See Fixed.MtimeScanner(m0-http): the change detector, suffix-filtered with the suffix supplied by the caller som0-httpkeeps no notion of what a source file is. One number per pass — newest mtime and file count, because deleting the newest file leaves the maximum in the past — compared against the previous pass rather than a high-water mark.__pycache__,.git,node_modulesand dotfiles are skipped; the first pass records and never reports.waitpid_nonblocking(WNOHANG) is what lets the supervisor poll instead of parking inwait.--health-path PATHanswersPATHin Mojo with a liveness JSON — under--realtime, with the livesubscribersandsocketscounts, which is how the smokes assert that a vanished client was actually unsubscribed. Opt-in, and separate from--realtime, for the mirror-image reason: an application may already route/health, and a server that took the path silently would shadow it.
Changed
-
The WSGI bridge is 2.35x faster: 12,289 → 28,911 rps on
apps/wsgi_bareat one worker, p50 1.21 ms → 508 µs, p99 2.47 ms → 1.07 ms (same interpreter, two rounds).serialize_requestcost 48 µs per request — 77% of the bridge's whole per-request cost, and six times what the Python shim it feeds costs. Enumerating headers withkeys()+get()allocated a String per name and per value and linear-scanned for each, andcgi_header_nameallocated three more per header: ~70 String allocations to move twelve headers. The projection now walkscount()with the header map's own spans and writes the CGI name's bytes directly, uppercasing and mapping-to_in place — 48.10 µs → 0.44 µs. PEP 3333 conformance green;smoke-django's RSS guard still 0 KB over 10k requests.Recorded because the suspicion was wrong: the Python shim's environ parse looked like the culprit and is only 11.5 µs.
scripts/bench_bridge_parts.mojois the split that found it, andhandle()is now five-sixths of what remains. -
Headers.name_span/value_spanare public (were_name_span/_value_span), so headers can be projected into another representation without allocating.keys()is unchanged and still right for callers that want owned Strings. A fork change; see NOTICE. -
Stage B is justified — reversing the no-go recorded earlier in this release cycle. That verdict came from a keep-alive benchmark on a hello route, which cannot produce the failure Stage B is half designed for, and it named a mixed-workload run as the gate. That run (
scripts/bench_mixed_workload.sh, 3.14.7t, two rounds) is decisive: one slow view alongside fast traffic takes fast-request p99 from 1.6 ms to ~194 ms (~120x) while p50 does not move — a subset of connections stopped dead, not general slowdown.--threadsis affected identically, because a keep-alive connection belongs to the loop that accepted it in both modes. Granian's--blocking-threads, which is the Stage B architecture, is flat under the same load (0.96 → 1.22 ms). -
The Granian throughput gap is the WSGI bridge, not the HTTP layer or the concurrency model (
scripts/bench_layer_split.sh). Three rows differing by one layer, byte-identical 13-byte response:apps/hello(zero Python) 78.3k rps at 178 µs, the same HTTP layer through the bridge 12.4k at 1.18 ms, Granian through its own bridge 124.6k at 109 µs. The bridge costs ~1 ms per request because the shim rebuilds the WSGI environ in pure Python every time (~28 string decodes for a twelve-header request) — itself downstream of thePythonObjectreference leak that forced the blob design. Building the environ through the raw CPython C API sidesteps the leak;PyDict_New/PyDict_SetItemwere compile-checked as reachable viaPython().cpython(). -
apps/django_wsgi's/slowaccepts?ms=, defaulting to the 1500 mssmoke-djangoexpects. The mixed-workload benchmark needs a much shorter hold — 1.5 s swamps the signal instead of measuring it.
Fixed
- A bare
://anywhere in a request target was read as a scheme, so a query parameter carrying an unencoded URL (/go?url=http://x) was parsed as a URI whose scheme was/go?url=httpand answered400before reaching the application.URI.parsesearched the whole target for://;scheme_separatornow accepts one only when everything before it is a scheme as RFC 3986 §3.1 defines it — ALPHA, then ALPHA / DIGIT /+/-/.— a character set that by construction cannot contain/,?or#. It is computed before theByteReaderborrows the string: a second interior reference taken while the reader holds one invalidates it. Clients that percent-encode — every browser form, everyurlencode— never hit this, which is what kept it a Known issue rather than a bug report.test_uri_scheme.mojocovers both directions andsmoke-wsginow sends its/reentrant?url=unencoded as well as encoded, so a real server proves it.
Removed
apps/django_realtime/server.mojo, and with itM0_DJANGO_PROJECT. The row keepsm0pub.py,djangoproj/,realtime_probe.pyandstatic/, and is served bybin/m0serve djangoproj.wsgi:application --app-dir apps/django_realtime --realtime --health-path /health. No WSGI row has aserver.mojoany more.
0.5.0 — 2026-08-22
The release the server grew a command line and a second way to be
concurrent. m0serve is one built binary that serves any WSGI application,
so three example apps stopped carrying a server.mojo each; --threads N
runs N event loops on N pthreads in one process on free-threaded CPython,
at throughput parity with prefork for ~60% of its RSS.
Added
--threads N/M0_THREADS— the threaded execution mode (free-threaded CPython only). N event loops on N pthreads in one process, one interpreter: each thread runs its ownrun_event_loopwith its ownWSGIHandler, and so its ownWSGIApp, bridge and shim namespace — the bridge's per-process singletons become per-thread without a line of the bridge changing, and the event loop is untouched. What it buys: one RSS instead of N, the app imported once, and the whole fork-after-init hazard class gone. What it does not: a keep-alive connection stays pinned to its loop, exactly as under prefork, so the keep-alive p99 shape is unchanged (the thread-pool stage is recorded in ROADMAP.md).m0_wsgi.threadedis the choreography — main initializes and imports before spawning and then detaches; every thread attaches once, serves, and releases;DetachingBackendwraps the loop's one blocking wait so a parked thread never stalls the others' stop-the-world; the process-wide signal pipe wakes a coordinator that pokes one shutdown pipe per thread. A GIL-enabled interpreter refuses to start with exit 78 and a sentence naming the requirement — never warns-and-runs.--threadsand--workersare mutually exclusive.wsgi.multithreadis finally True somewhere; every response carriesx-thread.smoke-threadspins the guard on every runner and the mode itself on the free-threaded canary (phase D ofpy-canary) — green on both backends as of 2026-08-23: kqueue on macOS and epoll on Linux, with all four loops accepting in each, so a listener dup'd into N epoll instances underEPOLLETwakes every one of them and noEPOLLEXCLUSIVEfollow-up is owed.- The threads-vs-prefork benchmark row (
docs/WSGI_PERFORMANCE.md,scripts/bench_wsgi_modes.sh): on 3.14.7t,--threads Nis at throughput parity with--workers N(0.92–1.05x) at ~60% of its RSS, ~3.5x gunicorn on the same free-threaded interpreter. ThreadHandler(m0-wsgi): anHTTPServicethat constructs itself on a serving thread via a staticmake(ctx). A trait rather than a function parameter because Mojo 1.0 cannot materialize a function-parameterizeddefas the runtime value a pthread needs;WSGIHandlerimplements it from theServeOptionsatctx.user.m0_http.threads— raw pthreads from Mojo, packaged:ThreadSet(malloc'd Int64 argument blocks,pthread_create/pthread_jointhroughexternal_call, adef's address as the start routine),ThreadBlock,ShutdownFanout(one shutdown pipe per thread, poked together — the event loop never drains its pipe, so N loops cannot share one),dup_fdandread_one_byte_blocking. The idiomscripts/py_thread_probe.mojoproved, now importable undermojo runand tested without an interpreter (test_threads.mojo). Knows nothing about Python; that discipline belongs tom0-wsgi. The substrate for the threaded execution mode — nothing consumes it yet.M0_THREADSis read byAppConfig(default 1) andthreads_conflict(workers, threads)answers the one message for asking for both execution modes at once. Mutually exclusive withM0_WORKERS>1. Read and validated ahead of the mode that will consume it, so the environment andm0serve --threadswill say the same sentence.m0serve— the uvicorn-shaped serve CLI. One built binary (poe build-serve→bin/m0serve) serves any WSGI application:m0serve MODULE[:ATTR] --host --port --workers --app-dir --static PREFIX=DIR --static-cache-control --access-log --max-body --metrics.ATTRdefaults toapplication;--app-dir(default.) is prepended tosys.path. EveryM0_*variable keeps its meaning with the matching flag winning over it, and flags are strict where the environment loader is lenient —--port 80eightyis a usage error (exit 2), not a silent default. Startup failures exit 1 and name the thing (a missing app dir is caught before any interpreter starts; a module or attribute that will not import is reported in Python's own words).--max-bodyand--metricsare the first two server-onlyServerConfigtunings a command line can reach. The entry file lives at the package root (packages/m0-wsgi/m0serve.mojo), outsidesrc/, for the reasonsm0-core/ffi_exports.mojodocuments; the parser (src/cli.mojo) is pure and interpreter-free, tested intest-wsgi.WSGIHandler(m0-wsgi): the one copy of the handler three example apps used to carry identically, with static mounts (List[StaticFiles]) answered in Mojo ahead of the bridge.M0_HOST: the listen address, read byAppConfigand honoured byaddress(). An IPv4 literal, orlocalhostfor127.0.0.1; the listener is IPv4-only and resolves nothing.poe smoke-serve:--help/--version, the usage and startup exit codes (including a supervisor that gives up under--workers), flag-over-env precedence, a static mount with itsCache-Control,--max-body→ 413,--metrics, and a graceful SIGTERM.
Changed
- The Django, Flask and bare-WSGI rows are Python-only projects served by
m0serve;serve-django,serve-flask,serve-wsgi-bareand the three smokes build the CLI once and reuse it. What the rows assert is unchanged. WorkerSupervisorexits 1, not 0, when its respawn budget runs out with a worker still dead — a worker that crashes on every attempt usually could not start at all (a bad module path), and exiting 0 reported success to whatever launched the server.test_respawn.mojopins it.
Removed
apps/django_wsgi/server.mojo,apps/flask_wsgi/server.mojoandapps/wsgi_bare/server.mojo, and with them theM0_FLASK_PROJECTandM0_WSGI_PROJECTvariables — replaced bym0serve … --app-dir.apps/django_realtime/server.mojokeeps its ownmain()andM0_DJANGO_PROJECTuntil the hold/publish machinery moves behindm0serveflags.
0.4.0 — 2026-08-22
The release the WSGI host grew up in. m0-wsgi went from a spike to a
framework-agnostic PEP 3333 server with a conformance suite, a second
framework row, and a realtime story that gives synchronous Django the SSE
and WebSocket surface people adopt ASGI for. Separately, the HTTP hot path
got substantially faster — headers alone are worth +72% throughput.
Added
-
In-process GRIP: sync Django holds SSE streams and WebSockets. A view answers an ordinary buffered response carrying
M0-Hold: streamorM0-Hold: websocketplusM0-Channel, and the server takes the connection from there.take_hold(m0-wsgi) consumes the instruction headers; an SSE hold keeps the view's body as the head of the stream, and a WebSocket hold cannot — a handshake answers101with aSec-WebSocket-Acceptderived from the client's key, which a buffered, re-encoded WSGI response has no way to produce. So Django approves and the Mojo layer performs the upgrade. Inbound frames make the return trip as ordinary requests:ws_message_requestgives a message the shape of aPOST(payload as body, channel/slot/opcode asM0-headers) and a plain synchronous view handles it.Under a server that has never heard of these headers the same views degrade to short buffered responses — the GRIP property. The header names are M0-prefixed because this is GRIP-shaped, not GRIP-compatible.
-
Publishing that never enters Mojo. The server exports its
BroadcastBuswrite fds once, pre-fork, asM0_BUS_WRITE_FDS;m0pub.py(stdlib only) frames an event andos.writes one datagram per worker, including its own. NoPythonObjectcrosses the bridge, so the reference leak rule andsmoke-django's RSS guard are untouched. One line in a sync view reaches SSE and WebSocket subscribers on every worker: the bus carries one SSE frame and delivery re-encodes per slot, so anEventSourceclient and a WebSocket client on the same channel see byte-identical messages. -
Numbered event ids, so
Last-Event-IDmeans something. Each publish fetch-adds oneInt64on theMAP_SHAREDpage the server allocates pre-fork, and the number goes into the bus datagram's id field and onto the wire as anid:line — which engagesSSERegistry's redelivery filter. An SSE hold seeds that filter from the request'sLast-Event-ID. Python has no atomic read-modify-write over a raw address, som0_shared_fetch_addjoins m0-core's C ABI andm0pubcalls it throughctypes. AbsentM0_CORE_LIB/M0_SHARED_ID_ADDRit degrades to unnumbered frames — the only behaviour available under a plain WSGI host. Suppression, not replay: catching a client up on missed events needs a journal, whichDatastarStreamhas and the raw registry does not.apps/django_realtimeis the working demo;poe smoke-django-realtimeandpoe smoke-django-realtime-wspin it, the latter with four held connections across two workers — one SSE stream and one socket each — all reached by ONE synchronous Django publish. -
PEP 3333 conformance testing, framework-free.
apps/wsgi_bareis a plain WSGI callable with no third-party imports, andpoe smoke-wsgiis the conformance suite over it: thewrite()callable, a secondstart_response, multi-chunk iterables,close(), arbitrary status passthrough, andwsgi.inputread patterns.smoke-djangogains a pass underM0_WSGI_VALIDATE=1, wrapping the app inwsgiref.validate, with a/pep3333/canaryroute that must fail under the wrapper so a misspelled variable cannot silently downgrade it to a second unvalidated run. Reasoning, including why repointing Django's owntests/servers/here was rejected, is in docs/WSGI_CONFORMANCE.md. -
Flask as a second framework row.
apps/flask_wsgiandpoe smoke-flask, with the assertions both rows share extracted intoscripts/wsgi_framework_contract.sh— routing, both directions of the cookie path, body round trips past the shim's 64KB transfer buffer, binary safety, query parsing, the framework's own 404, and a raising view becoming a 500. A row needing assertions of its own would be evidence the host is not framework-agnostic after all. Adding Flask needed no change tom0-wsgi. -
Graceful shutdown on SIGTERM/SIGINT. The loop always knew how to drain — close the listener,
: closeto SSE clients, a 1001 frame to WebSocket clients, in-flight requests for up to 5s — and nothing could ask it to.install_shutdown_signals()returns the fd to pass asshutdown_read_fd. Mojo has no globalvarand a POSIX handler gets no user-data pointer, sosrc/global_slot.mojoreachespop.global_allocfor what C spellsstatic; if that ever stops working nothing is installed and the default disposition stands, whichshutdown_signals_active()reports.WorkerSupervisorpropagates a signal aimed at the supervisor alone to its children — whatdocker stopdoes, and what used to leave workers orphaned on the port.poe smoke-shutdowncovers both paths. -
Static files ahead of the bridge.
StaticFilesgrew aCache-Controlpolicy, emitted on 200/206/304 (a validator response carries freshness too, per RFC 9110), and the Django rows mount it: asset requests are answered in Mojo with type, ETag revalidation and traversal 404s, and never enter Python. WhiteNoise has nothing left to do. Zero-copysendfileremains recorded, not built — it needs event-loop support for fd-backed response bodies. -
m0-sqlite: the result codes callers actually branch on are exported (SQLITE_CONSTRAINT,SQLITE_RANGE,SQLITE_NOMEM, plusSQLITE_OPEN_NOMUTEX/FULLMUTEXso a caller assembling flags can reproduce the package's threading model).sum_ints,min_ints,max_intsandstats_intsare the SIMD passfetch_ints' column-major layout was written for: over 200k rows,fetch_ints + stats_intsbeatsSELECT sum(v), min(v), max(v)8.63 ms to 11.84 ms — mostly because the read-out runs one column fetch per row where SQLite runs three aggregate steps through its bytecode VM, not because of the vectorization, which is 0.5% of that pipeline. -
sse_data_payload(m0-http) — the inverse offormat_sse_event, returning what a browser'sEventSourcehands toonmessage; andSSERegistry.filter_url, the inverse ofsubscribe. -
CI that cannot quietly rot. A warning ratchet (
scripts/warning_ratchet.py,poe check-warnings) holds the unique warning count at a committed baseline, becausemojohas no per-warning suppression and warning number 69 would otherwise land among 68 residual ones unnoticed.poe canaryruns the whole Mojo-nightly probe in one command, with the toolchain restore in anEXITtrap so it happens even when the canary fails.poe py-canaryruns the WSGI suite against free-threaded CPython 3.14t and now runs weekly;poe py-thread-probemeasures Mojo-spawned pthreads calling Python — 3.96x at 4 threads on thread-local state, and 0.71x on a shared dict, a confirmed per-objectPyMutexmechanism recorded in docs/WSGI_VS_ASGI.md.
Performance
Each figure is against its own baseline in its own session; docs/SERVER_PERFORMANCE.md records a 1.7x session-to-session swing on identical binaries, so they do not chain.
- Headers stored as spans into a flat buffer, not a
Dict: +72% throughput — 29,000 → 50,000 req/s onapps/hello, p50 535 → 320 µs, five alternating A/B rounds. ADict[String, String]cost two allocations per header to fill, a third to lowercase each name, and a fourth per lookup, becausekey.lower()builds a probe copy before it can hash. One blob indexed by parallel (offset, length) arrays makes a lookup a linear scan that compares lengths first and allocates nothing — and preserves insertion order, which theDictnever guaranteed. - The hot path cut to two syscalls per request: +24% — 15.2k → 18.9k
req/s, p50 1.03 → 0.83 ms. Persistent read-filter registration
(
slot_read_armed) removes anepoll_ctlADD that failedEEXISTevery time and the MOD it fell back to; idle timeouts move to a once-a-second deadline sweep instead of a per-requesttimerfd_settime;TCP_NODELAYon accepted sockets. Router.matchon spans: 2.8x on/health(158 → 57 ns). Patterns live in one flat blob and matching walks the path by moving span endpoints; nothing allocates until a parameter is captured on a route that matched, and a 404 allocates nothing at all.- WebSocket unmasking and UTF-8 validation vectorized — the 4-byte mask splats across 64 lanes (64 is a multiple of 4, so the pattern stays phase-aligned and the scalar tail needs no special case), and text validation skips pure-ASCII runs 64 bytes at a time while every non-ASCII byte still goes through the same strict decoder.
- Startup RSS down 47–64% — connection buffers are sized on first use
rather than at pool construction, so a server no longer allocates for its
configured ceiling before accepting anything.
apps/hello: 26.5 → 14.1 MB at one worker, 77.7 → 28.0 MB at four. - Log lines assemble in one buffer instead of a dozen
Strings. - The per-slot response buffer is reused via
encode_into, which had sat unused behind a comment claiming Mojo could not move out of a list-element field. It can, byswap. The honest result is 1.04x, andSERVER_PERFORMANCE.mdwas corrected to say so rather than leaving the item ranked first.
Changed
- The Django example enables
django.contrib.sessionson the signed-cookie backend, so it still needs no database.poe smoke-djangoasserts request cookies reach a view intact (including a value containing=), that splitCookiefields rejoin, that a cookieless request stays cookieless, and that a session counter advances across three requests. AppConfigmaps toServerConfigin one place instead of once per app.- Mojo 1.0 deprecations cleared across the repo where a replacement ships:
the memory and origin APIs, positional pointer indexing,
memcpy→unsafe_memcpy,deinit take:→deinit move:, andhttp/common_response.mojoimporting the names it uses instead of resolving them through a star-import — that pattern alone accounted for 57 of the repository's then-143 unique warnings. - The cross-worker smokes place one stream per worker deterministically
(SIGSTOP the worker that won the first open, then open the second) rather
than racing accept. Which worker wins is the kernel's choice and it is not
a fair one: a macOS runner handed a single worker all 24 opens across six
rounds, and opening in bursts made it worse, because the accept path
drains the backlog until
EAGAINand the first worker to wake takes the whole burst.
Fixed
-
Request cookies never reached a WSGI application. The request parser diverted
Cookieout of the header map intoRequestCookieJar, and the WSGI environ is built by walking the header map — soHTTP_COOKIEwas absent andrequest.COOKIESwas always empty. Every Django session, login, CSRF check and message silently behaved as though the visitor had arrived with no cookies at all.Cookienow stays inheadersas well as feeding the jar, and severalCookiefields are rejoined into one"; "-separated list (RFC 6265 §5.4) rather than collapsing to the last one.Set-Cookieon a request is no longer folded into the request's own cookies — it is a response field, and treating it as one invented a cookie the client never sent.Because a parsed request now carries its cookies in both places,
encodeandwrite_towrite the jar only whenheadersdoes not already carry the field, so re-encoding a parsed request still emits oneCookie. -
RequestCookieJarmis-parsed values, and its lookups did not match its storage. Pairs were split on every=rather than the first, so any value containing one was truncated at the first segment — base64 pads with=, so a Djangosessionidroutinely lost its tail. Splitting also ran over the whole field instead of per cookie, soa=1; b=2parsed as one cookieaholding1; b. A pair with no=was stored under the empty name instead of being ignored (RFC 6265 §5.2). And__getitem__lowercased the key while stores,__contains__andto_headerdid not, so a jar holdingsessionIdanswered nothing to any spelling; cookie names are case-sensitive (RFC 6265 §4.1.1) and are now treated that way throughout. The jar's ownparse_cookieswas dead code —HTTPRequesthand-rolled a separate, buggier copy — and both now share one path. -
A request body that could not be read in one
recvnever completed. The event loop registered read interest only while a connection was inREADING_HEADERS; once headers parsed and the state moved toREADING_BODY, nothing armedEVFILT_READagain. Since epoll is edge-triggered, body bytes already waiting in the socket buffer raised no further edge either, so the connection stalled untilbody_read_timeoutanswered408. Both the transition intoREADING_BODYand each incomplete body read now re-register read interest.This hit every request whose body did not arrive inside the first 4KB staging read — any POST or PUT over ~4KB, and any request at all whose client flushed headers before the body, regardless of size. It affected every app in the repo, not just the WSGI host: Django form posts, file uploads and JSON APIs all timed out.
poe smoke-djangonow posts a 256KB binary body and a header-flushed-first body to/echoand compares the echo byte for byte. -
write()discarded every byte. The WSGI shim returnedlambda data: None, so an application using the legacy write callable got a 200 with an empty body and no error anywhere. Django never callswrite(), so nothing Django-shaped could have caught it — including thewsgiref.validatepass, which type-checks the call and not its effect. The iterable is now drained before the writes are joined, because an application may callwrite()from inside the generator it returned. Found byapps/wsgi_barewithin minutes of its existing. -
A second
start_responsewithoutexc_infowas silently accepted, last call winning. PEP 3333 makes it an application error; it now raises. Withexc_info, replacing the stored status and headers is always correct for a fully-buffering server, since nothing has ever been sent. -
Keep-alive connections answered
408to prompt requests.slot_header_startwas re-stamped in_after_send, so the header read deadline measured from the end of the previous response rather than the start of the current request: any connection idle longer thanheader_read_timeoutgot a408for a request the client had just sent promptly and completely. Measured at the boundary — a 9s gap answered 200, an 11s gap answered 408. -
m0-sqliteanswered questions it should have refused.sqlite3_column_namereturns NULL pastcolumn_count()andcstr_lendereferenced it, segfaulting on an out-of-range index; NULL is now guarded incstr_len, one place for every caller. Out-of-range reads were indistinguishable from NULL —column_intanswered 0,column_text"", andis_nullanswered True for a column that does not exist, so the one accessor whose job is removing that ambiguity was adding one. Every reader now bounds-checks, re-reading the count per call becauseprepare_v2silently re-prepares after a schema change and aSELECT *can change its column count mid-life. -
verify_layout.cguarded shipped code and nothing ran it. It re-derives with_Static_assertevery SQLite struct offsetvtab.mojohardcodes as a flat word buffer, but sat inexperiments/, which nothing builds — so the claim that "every offset used here is asserted against the real headers" was aspirational, guarding a failure mode (a wrong offset silently corrupting every row after the first) this repo has shipped once before. It moved intopackages/m0-sqlite/test/andpoe test-sqliteruns it first. -
Three latent faults in the lightbug fork, each reachable only when the import graph is entered from a particular direction and so invisible until one was:
cookie/request_cookie_jar.mojousedHeaderswithout importing it (it resolved through theheader↔http.parsingcycle on the usual path), anduri.mojo's__str__andis_httpstill calledlen(String), which Mojo 1.0 rejects. Bodies elaborate on demand, so all three compiled cleanly until a consumer reached them.
Known limits
Newly documented rather than newly true, and worth knowing before turning on more workers:
urlopenfrom inside a view SIGKILLs the worker on macOS underM0_WORKERS>1:_scproxycalls into CoreFoundation, and Objective-C aborts rather than run in a process forked without exec. Usehttp.client.HTTPConnection, which does no proxy lookup;apps/wsgi_bare's/reentrantroute is the worked example andpoe smoke-wsgipins it. The general rule — afterfork()withoutexec, platform runtimes are off limits, from application code too — is what the free-threading path is expected to retire.
0.3.0 — 2026-08-18
- WebSocket text messages are now validated as UTF-8 (RFC 6455 §8.1) on the assembled message — a multi-byte character split across fragments is fine; an invalid sequence closes with 1007. Binary frames still carry any bytes.
StaticFileshonours single byte ranges (RFC 9110 §14):bytes=a-b,bytes=a-, andbytes=-suffixanswer206+Content-Range; parseable-but-past-the-end answers416withbytes */total; multiple ranges and other units are ignored (full200, as the RFC permits).Accept-Ranges: bytesis advertised;If-Rangedeliberately never matches (weak ETags, strong comparison required) and falls back to the full representation.apps/hello(and the README example) now use the non-blocking event loop — the blocking accept loop remains in the fork but has no in-repo app consumers left.Clientkeep-alive: response boundaries are now computed (classify_response— Content-Length, chunked terminal chunk + trailers, bodiless statuses, HEAD) instead of inferred from EOF, and the connection is kept warm and reused across requests to the same host and port. Reuse rules are conservative (aConnection: closeresponse, an HTTP/1.0 peer, a close-delimited body, or stray bytes past the boundary all retire the connection); a reused connection that dies before yielding a single response byte is retried once on a fresh dial.keep_alive=Falserestores one-connection-per-request.connections_openedreports dials; the smoke asserts a six-request conversation (HEAD included) rides one connection. Breaking:request/get/postnow takemut self.m0_http.WSHub— the handler-side WebSocket registry: connected slots, per-slot outboxes, room broadcast, and cross-worker fan-out over the sameBroadcastBusSSE uses (the bus is transport-agnostic;sse_peer_framedelivers encoded WebSocket frames as readily as SSE events). Newapps/ws_chatdemo — one room, every message reaching every socket acrossM0_WORKERS— andpoe smoke-chat, which proves a message sent on one worker's socket arrives on the other worker's.
0.2.0 — 2026-08-17
- WebSockets (RFC 6455), server side:
websocket_upgradeanswers the opening handshake from an ordinary handler, the event loop parses frames (client masking enforced, fragments assembled, ping/pong and the close handshake answered in the loop), and complete messages arrive at the newHTTPService.ws_messagehook — the ninth trait method, empty in handlers that never upgrade. Outbox, heartbeat (a protocol ping on theM0_SSE_HEARTBEAT_MScadence), and disconnect plumbing are shared with SSE. Protocol violations answer with the RFC's close codes (1002/1009). Newapps/ws_echodemo;poe smoke-wsproves the wire format against a from-scratch stdlib client. Also fixed in passing: a stale keep-alive idle timer could fire mid-stream and kill an SSE connection opened on a reused keep-alive connection. m0_http.StaticFiles— static file serving: a directory mounted under a URL prefix, with lexical path-traversal defense (decoded.././empty segments answer 404), extension-based content types, and ETag/304revalidation. The notes example serves/static/with it.HTTPService.tick(now_ms)— the application timer hook, fired everyM0_APP_TICK_MSmilliseconds (0 = off, the default) on the event loop's timer. Server-initiated pushes no longer need an inbound request; the counter demo gained a live uptime clock driven by it, ticking on one designated worker and reaching every worker's tabs over the broadcast bus. Breaking for handler authors: the trait gains an eighth method (emptytickin non-scheduling handlers).
0.1.0 — 2026-08-17
First release. Everything below is new.
The server (lightbug_http, a maintained hard fork)
- HTTP/1.1 server, Linux (
epoll) and macOS (kqueue), forked from lightbug_http v26.1.2 after upstream was archived — see NOTICE and PROVENANCE.md. - Non-blocking event loop: multiplexed keep-alive connections,
header/body/idle timeouts, graceful shutdown that drains in-flight
requests, opt-in Prometheus-format
/__metrics. - Request-parsing hardening: request smuggling (CL+TE, duplicate
Content-Length,chunkednot last), header-count and size caps, request-target normalization, chunked-size integer overflow — each guard pinned by a test verified to fail without it. - SSE as a first-class server concern: per-slot outboxes with backpressure,
Last-Event-IDredelivery suppression, heartbeats on idle streams (M0_SSE_HEARTBEAT_MS) that double as dead-subscriber detection. - Cross-worker SSE fan-out: a pre-fork
BroadcastBus(one datagram channel per worker) plusSharedAtomicsevent ids makeM0_WORKERS>1and SSE compose; a broadcast on any worker reaches every worker's subscribers. fcntl(F_SETFL)fixed on ARM64 macOS (Darwin passes variadic arguments on the stack):set_nonblockingnow actually works there, which is what lets two workers race on one shared listener without the loser blocking insideaccept().- Outbound
Client: GET/POST/any method with full response parsing — Content-Length with loud truncation detection, chunked, and close-delimited bodies.
The framework (m0-http)
- Router with
:paramcaptures and real405+Allow. - Content negotiation:
Accept(quality factors, wildcards,*/*resolves to JSON),Accept-Encoding(codec-agnostic, RFC 9110identity/*/q=0 rules),Accept-Language(RFC 4647 matching, serve-something-over-406). - Weak ETags (wyhash) with
304 Not Modified, URL-keyed response cache. - API-key auth with constant-time comparison, CORS hooks, health/readiness
registry, JSON-lines access logging,
M0_-prefixed env configuration. - Multi-worker fork supervisor with crash respawn: workers accept from one shared pre-fork listener; a respawned worker takes over its predecessor's identity (index, bus channel).
Datastar (m0-datastar)
- Datastar v1.0.2 wire format with zero dependencies (
consts,sse), so frames are usable without the framework. DatastarStream: subscriptions, five broadcast shapes,read_signals, andLast-Event-IDreplay from a bounded frame journal — including across restarts when the app persists the journal (the todo example does, in ~15 lines of SQLite).
WSGI (m0-wsgi)
- Runs Django (or any WSGI app) on this server by embedding CPython. Bodies
cross the boundary as raw addresses (Mojo 1.0 binds no
bytesAPI), and per-request data avoids the toolchain'sPythonObjectreference leak by design — an RSS guard in CI keeps it that way. Prefork viaM0_WORKERS, benchmarked at ~1.6–2.2x gunicorn's throughput on the same Django app.
SQLite (m0-sqlite)
- Connections, statements, typed columns, transactions, bulk read-out — WAL by default, busy timeouts, honest error text. A sibling package that imports nothing else here. Measured guidance in docs/SQLITE_PERFORMANCE.md.
C ABI (libm0core)
poe build-ffiemitslibm0core.so/.dylib(FNV-1a, xxHash32, wyhash64, JSON escape) for Bundlopen, N-API, orctypes; release artifacts for Linux and macOS are attached to GitHub releases.
Examples (apps/)
hello— the whole server in one file.notes_api— the framework showcase: negotiation, ETags, problem+json, CORS, validation.datastar_counter— multi-tab live sync; the reference wiring for cross-worker fan-out and shared-memory state.datastar_todo— the flagship: HTML-over-SSE broadcasts, SQLite persistence, and SSE replay across restarts.django_wsgi— a real Django project served by the WSGI host.