Capabilities
One row per capability with the test that proves it and how often the test runs. Also available as spec.json.
What this server implements, and what proves it. One row per capability, each with its evidence: a CI step and its cadence, a test function, a roadmap heading, or the reason for a refusal.
412 capabilities: 386 verified, 0 implemented, 0 planned, 26 out of scope. Of the 386 verified, 378 are gated on every pull request, 3 weekly, 1 monthly, and 4 before a release. Every pull-request-gated row's coverage is declared IN its gate (covers: in the cited test, or a recorder coverage call in what the cited step runs), and the checker requires the declaration and the citation to agree; the weekly, monthly and pre-release rows keep declared-static citations, their runs being absent from PR CI.
How to read this page
Rows have a permanent id (A7, K3). Ids are never renumbered or reused;
refer to a row by its id, since the wording is edited as understanding
improves.
| status | means |
|---|---|
verified |
a named CI gate exercises this, on the cadence the evidence states |
implemented |
in the tree, with no gate dedicated to it: the work queue, not a claim of correctness |
planned |
intended and not built; the evidence names the roadmap heading |
out of scope |
a deliberate refusal; the evidence is the reason |
verified says a gate runs, not that the capability is correct: no checker
reads a test's meaning, and a gate can be looser than its row. Cadence is
part of the evidence. (every PR) is a step in .github/workflows/test.yml.
(weekly) is py-canary.yml or nightly-canary.yml on a cron; the
free-threading rows live there because CI pins GIL-enabled 3.13.
(monthly) is citations.yml, which asks the RFC Editor whether the cited
RFCs are still current. (pre-release) is a gate docs/RELEASING.md
requires and CI does not run, because shared runners cannot reproduce the
timing.
poe check-docs fails the build when a verified row names a gate that
does not exist or does not run, and when a CI step or a CLI flag is
accounted for by no row (scripts/spec_sheet.py). The badges above report
the newest run on main, not the commit you are reading. One external
conformance suite runs, Autobahn at pre-release (I13); the WebSocket and
smuggling rows are otherwise pinned by probes and unit tests written
against the RFC text. How the rows were read against their gates, and what
that found, is in the traceability note.
A. HTTP/1.1 framing and connection lifecycle
| id | capability | status | evidence |
|---|---|---|---|
| A1 | Persistent connections (keep-alive) | verified | Smoke test pipelined requests (every PR) |
| A2 | The example Mojo server starts and answers /health |
verified | Smoke test the hello server (every PR) |
| A3 | Keep-alive request cap (--max-keepalive-requests / M0_MAX_KEEPALIVE_REQUESTS; 0 = never) |
verified | Smoke test the keep-alive request cap (every PR) — closes on the cap request, and a stream or WebSocket upgrade landing there survives it: a stream is delivered whole under a head that says close, and the connection ends after it, the stream's end carrying the cap as it carries a request's own close (slot_close_after_stream); the probe's third phase refuses to pass on a build whose cap never fires, and its fourth on one that keeps the connection after the stream, where the cap was one request soft and a second stream was cut after its head (2026-10-09). The unit form is test_slot_lifecycle.mojo:test_a_chunked_stream_as_the_cap_request_says_close_and_closes_when_it_ends. The smoke pins the cap at 100 explicitly, so the gate is independent of the default. No flag or env var exposes the limit |
| A4 | Idle connection timeout, --idle-timeout |
verified | Smoke test the idle connection timeout (every PR) — an answered keep-alive connection left quiet is closed at the deadline and no earlier, one kept busy across it is not, and an upload begun just before the deadline completes: a request's first bytes end it. It used to stand until it passed, and cut a request that started late in the window mid-body (review B2); the busy phase resets it with every request, so it could not see that |
| A5 | Header read timeout (slowloris defence) | verified | Smoke test the header read timeout (every PR) — a silent connection, answered 408 and closed by the once-a-second sweep. Met at a read too: a head whose first bytes came longer ago than the timeout is answered 408 by the read that brings more of it, with the lingering close of A20 (the write side shut behind it, what the client still sends discarded, the connection closed once the client closes), and the same head completed in time is answered, test_slot_lifecycle.mojo:test_a_head_still_arriving_past_its_timeout_is_answered_408_at_its_next_read. That read's check comes before it reads, so it closed with the rest of the head unread, which resets a connection: on Linux the client's read failed ECONNRESET and the 408 was lost (review record LF75); sabotaged back to the close, the test fails so on Linux and at its linger assertion on macOS |
| A6 | Request pipelining, answered in order (RFC 9112 §9.3) | verified | Smoke test pipelined requests (every PR) |
| A7 | Chunked request bodies, decoded incrementally across reads | verified | test_parsing.mojo:test_incremental_decode_matches_a_single_pass (every PR) |
| A8 | Chunked decode consumes the terminator, leaving nothing buffered | verified | test_chunked_encode.mojo:test_round_trip_single_chunk (every PR) |
| A9 | Chunked response bodies | verified | Smoke test streamed WSGI bodies (every PR) |
| A10 | Trailer fields consumed and discarded, not surfaced to the application | verified | test_parsing.mojo:test_a_trailer_section_is_consumed_whole (every PR) — with seven more beside it: the framing fields a trailer must not honour, the pipelined tail surviving it, the section bounded by the abuse ratio, and test_without_consume_trailer_the_body_ends_at_the_zero_chunk as the other half. poe sabotage-trailers reverts each of the rules, these and B14's, and requires a failure for every one |
| A11 | Expect: 100-continue, honoured case-insensitively and withheld from HTTP/1.0 |
verified | Expect 100-continue, honoured and withheld (every PR) — both directions: a server answering it unconditionally would pass the invitation half while discarding the refusal-before-upload the mechanism exists for |
| A12 | Half-close answered rather than dropped | verified | Smoke test a half-closed client (every PR) |
| A13 | Request headers and bodies larger than one socket read, at no cost to a read that takes what the socket holds: only a read that fills the buffer, or the peer's EOF, re-registers read interest | verified | Smoke test a request larger than one read (every PR) — requests up to the 32 KB header cap are answered and a larger one refused 431, on both platforms; on the Linux leg scripts/epoll_rearm_probe.py counts the server's epoll_ctl calls under strace, at most 0.5 per request over 2000 keep-alive requests (0.002 measured, each connection's own registration; 2.002 when every read re-registered, an ADD refused EEXIST and its MOD, review record R2), and 50 requests three reads long must each be answered and raise the count, so the meter is seen to read a re-registration before its silence is trusted. The body the same way, counting the loop's reads from the same trace: at most 0.5 per read of a body sent in 100 pieces 10 ms apart (0.030 measured, the connection's registration and the body timer's two; 1.99 when every body read re-registered), three 1 MB bodies in one write each answered within 10 s with the count rising at least once per read that filled the buffer, and a body cut short by a half-close, its FIN riding its last bytes, closed within 5 s rather than held to the 30 s body timeout. macOS has no unprivileged syscall counter, and kqueue's level-triggered reads never needed the re-registration |
| A14 | Host required on HTTP/1.1 |
verified | test_parsing.mojo:test_http11_requires_host_to_be_present_at_all (every PR) — an empty one is accepted where the target names no authority (B20) |
| A15 | absolute-form request target reduced to its path | verified | test_parsing.mojo:test_absolute_form_target_is_reduced_to_its_path (every PR) |
| A16 | Malformed field lines rejected, not treated as incomplete | verified | test_parsing.mojo:test_a_field_line_without_a_colon_is_invalid_not_incomplete (every PR) |
| A17 | Control bytes in the request target rejected | verified | test_parsing.mojo:test_a_control_byte_in_the_request_target_is_invalid (every PR) |
| A18 | HTTP/2 | out of scope | terminate at a proxy — gunicorn's answer, and the same one applies here |
| A19 | HTTP/3 and QUIC | out of scope | follows HTTP/2; there is no TLS layer to build it on |
| A20 | A refusal sent mid-upload reaches the client: lingering close (RFC 9112 §9.6) | verified | Smoke test a 413 reaching a client still uploading (every PR) — the whole body written before reading, Content-Length and chunked, through http.client, and eight at once; Connection: close and a FIN right behind the 413; the linger bounded against a client that keeps trickling past it, and a clean close at its deadline for a client that reads late |
| A21 | No Content-Length on a 1xx or 204 response, and none invented on a 304 (RFC 9110 §8.6) |
verified | test_response_head.mojo:test_a_204_invents_no_content_length_or_type (every PR) — with six more beside it: the constructors invent neither a length nor a type for a 1xx, 204 or 304, and a 304 keeps a length its handler gave; enforce_bodiless_framing, which the event loop applies to every such response on every path, drops a 1xx's or 204's own length and any body (Django's CommonMiddleware puts a length on every non-streaming response) and closes a file body. On the wire, Smoke test the notes API checks the native 304s, the OPTIONS preflight and a delete's 204 for an entity header nobody set, and both conformance steps send a 204 carrying a length and seven bytes followed by a request on the same connection: the only case that shows whether the loop calls the rule. Before the fix every native 204 and 304 carried content-length: 0 and application/octet-stream |
| A22 | An unencoded @ in a request target's path or query is data: the request reaches its own route with its query, whatever its method, and only an @ inside a URL's authority, before its first /, ? or # (RFC 3986 §3.2), ends a userinfo |
verified | test_uri_userinfo.mojo:test_an_at_in_a_request_query_keeps_the_path_and_query (every PR) — URI.parse skipped to the first @ anywhere after the scheme, so GET /echo?x=a@b reached the application as / with an empty query and a POST to /save?x=a@b ran the root's POST handler, on every server shape; the outbound client, which dials the parsed host, connected to whatever followed an @ in a query. Browsers and htmx encode @, which kept it to typed and hand-built URLs; a Flask app compared against Werkzeug found it. Its neighbours hold the path form (/users/@alice?page=2), the client's host, and a real userinfo still skipped, and Conformance test the WSGI bridge sends a GET and a POST carrying the raw byte to a live server and requires the path, the query and the method back. The :// half of the same search is test_uri_scheme.mojo |
| A23 | Request body timeout, --body-timeout: a body still short at the deadline is refused (408 on a connection's first request) and closed, and a body that arrived whole leaves no timer behind |
verified | Smoke test the request body timeout (every PR) — at a 2 s timeout, with --blocking-threads 2: a keep-alive connection outlives the timeout after a POST whose body came with its headers; a request out on a pool thread when the old timer would have fired keeps its slot, and eight connections opened behind it read only their own answers; headers and half a body, then silence, is answered 408 and closed at the deadline; and two arms stop the server so a read and an expiring timer share one wait, where each half of the fix is the only thing standing. The first refusal prints one line naming the flag, M0_BODY_TIMEOUT and the timeout in effect, and only one however many follow; M0_BODY_TIMEOUT=1 with no flag answers a stalled body 408 within five seconds, where the default waits 30; and --doctor's body_timeout is the effective value (Smoke test the serve CLI). The timer was armed for every body above the decode of what came with the headers, so a small POST left it running: 30 s later it closed the connection, and under the handler pool released a slot a pool thread still held, whose answer the next connection then read (review B1). An expiry that reaches a slot no longer reading its body is deleted, not only skipped, because epoll's timerfd is level triggered and one left registered is reported by every wait after it (kqueue's timers are one-shots): test_slot_lifecycle.mojo:test_a_stale_body_expiry_is_retired |
| A24 | A response its client stops reading is reaped after --idle-timeout with no send progress (nginx's send_timeout), and one read slowly but steadily is not. A stream, SSE or WebSocket, has no send deadline and keeps its slot until its client leaves, by decision: DECISIONS D57 |
verified | Smoke test the idle connection timeout (every PR) — a client that stops reading a 16 MiB response is closed, and one that takes it in slow reads for longer than the timeout gets every byte, for a response in memory and for a --static file sent with sendfile, which refresh the deadline on separate paths. Nothing bounded such a client before: m0serve zeroes the deadline of a request it hands to a pool thread or an executor, so a client that asked for a large response and never read it held its slot for good |
| A25 | A client that resets or leaves while the server writes to it costs that connection, never the process, and SIGPIPE ends no process a server runs: ListenConfig.listen ignores it first, before either host forks or starts a thread, so a supervisor, which never enters the loop (m0serve's under --workers or --reload, the Mojo host's under M0_WORKERS), and what a Mojo host application runs before its loop (its make, producer and pool threads) inherit the ignore; run_event_loop (the Mojo host's loops, m0serve's prefork and threaded ones, and every Server entry point's: listen_and_serve, serve and their _nonblocking forms) ignores it again before its first send, so the write fails with EPIPE and the connection closes. A built Mojo binary otherwise keeps the signal's default action, which ends the process; mojo run and m0serve's embedded CPython each ignore it on their own, which is why no gate saw it, and m0serve's supervisor forks before its first Python call |
verified | Smoke test the Mojo host (every PR) — apps/host_check, built, as one process: after kill -PIPE it must still answer /health, and again after a client asks for a 300 ms /slow and resets 50 ms in, so the answer is written to a reset socket. The signal is the arm that tells a fixed build from a broken one on both legs. Measured on Linux (a Debian container, 2026-09-29) with ignore_sigpipe made a no-op: kill -PIPE ended the server, the producer's write ended it and kill -PIPE ended the supervisor, 10 rounds in 10 each, and both steps failed at their kill -PIPE arm. The resetting client ended it in none of 10: there the send after an RST fails with ECONNRESET and raises no signal. In the same step, a one-worker server whose producer's make writes to a closed pipe before the loop starts must serve, the write failing with EPIPE, and the two-worker supervisor must survive kill -PIPE, then hold streams on both workers and reap both on SIGTERM; Smoke test spawned workers sends m0serve's supervisor kill -PIPE and requires both workers answering and the reap after. Measured before the fix, on macOS: that client ended a fresh built server with status 141 in 10 of 10 rounds, as did one leaving an 8 MB response after 1 KB (event loop and the then-blocking Server.serve alike), SSE subscribers resetting on 1 ms beats ended it within 8 to 34 resets, and kill -PIPE ended every shape. With the loop's ignore alone, kill -PIPE ended either supervisor with 141 and orphaned its workers, a stdout reader that left ended the host with 141 at its next line, and the producer's write ended the server before it served, with one worker and under M0_THREADS=2 alike, and killed worker 0 on every respawn under M0_WORKERS=2. The unit halves are test_sigpipe.mojo:test_a_write_to_a_closed_pipe_is_an_error_not_the_end and test_kill_pipe_does_not_end_the_process, which provoke the signal, and test_serve_runs_the_event_loop_which_ignores_sigpipe and test_listen_and_serve_runs_the_event_loop_which_ignores_sigpipe, which run each public entry point until its closed shutdown pipe ends the loop and read the disposition back; each puts the default back first, so under mojo run too a missing ignore fails it |
| A26 | A response carries at most one Server header: the application's when it sets one, the server's default otherwise; none when G2 drops the application's |
verified | test_response_head.mojo:test_a_server_header_the_application_set_is_the_only_one (every PR) — on the bytes of both encoders and the printed form. encode and encode_into wrote server: lightbug_http whatever the headers held, so an application that named itself, built in Mojo or served through the gateway, sent two Server lines, which RFC 9110 §10.2.4's grammar, a product list rather than a comma-separated one, cannot join into one; the printed form already asked first. Found by the 2026-10-07 fork review |
| A31 | A bracketed listen address takes its port from after the one colon that follows ]: [::1]:8:0 is refused as too many colons, not served on port 0 |
verified | test_ipv6.mojo:test_a_colon_inside_a_bracketed_address_port_is_refused (every PR) — parse_address read the port after the LAST colon wherever the bracket closed, so [::1]:8:0 listened on ::1 at a port the kernel chose and [::1]:8:80 on port 80 (review record LF33). Reached by an address a Mojo application passes Server.listen_and_serve; m0serve and the Mojo host bracket the --host they are given themselves |
| A32 | Socket.receive and TCPConnection.read into a buffer with no room past its length read what is waiting, growing the buffer, and report EOF only for the peer's |
verified | test_socket_spans.mojo:test_a_receive_into_a_full_buffer_reads_what_is_waiting (every PR) — the buffer's spare capacity was lent to recv as it stood, and a recv into zero bytes returns 0, the count EOF returns, so a full buffer, or a Bytes() never given a capacity, raised EOF with the peer's bytes still waiting (review record LF34). The test reads through both, each on a real socket pair, and requires a read after the peer has closed to raise EOF still. The server reads through the event loop and never called these; an application, and the tests' client, did. test_socket_spans.mojo:test_a_receive_at_the_peers_eof_raises_eof holds the other side: the peer's EOF, after its bytes, is EOF and leaves the buffer alone |
| A27 | A client that half-closes has sent its last request: the answer to the request that ends what it sent says Connection: close, and the connection ends behind it; a request pipelined ahead of that one, in the same read or still in the socket, is answered first, with the connection kept |
verified | test_slot_lifecycle.mojo:test_a_half_closed_request_is_answered_with_a_close (every PR) — one read event carrying the EOF with the bytes, as both backends report a FIN that arrived beside them, for one request, two in one read, and a first request exactly one read long with the second still in the socket. The EOF set the close and the request's own Connection replaced it, so the answer said keep-alive, and on epoll, the FIN's edge spent, the slot waited for the idle sweep, for good with idle timeouts off. Smoke test a half-closed client runs it on the wire on both legs: a half-closed burst of 200 pipelined requests whose last answer must close, and single requests whose connections must end within 5 s. Found by the 2026-10-07 fork review |
| A28 | A chunked response stream ends as its request asked: after Connection: close, on a server with keep-alive off, or for a client that has half-closed, its head says Connection: close and the connection closes once the terminator lands; otherwise it says keep-alive and the connection waits for the next request |
verified | test_slot_lifecycle.mojo:test_a_chunked_stream_keeps_the_close_its_request_asked_for (every PR) — a channel stream from head to terminator, for each of the three and for a request that asked nothing, which keeps its connection. A stream's head clears the close, or the head landing would end the slot, and the stream's end then took the keep-alive path whatever the request had said, under a head that said keep-alive: measured on m0serve with the bare ASGI app's /chunks and Connection: close. Found by the 2026-10-07 fork review |
| A29 | A half-closed request whose head is longer than one read is read to its end and answered, the FIN arriving with its first bytes or after them | verified | test_slot_lifecycle.mojo:test_a_half_closed_head_longer_than_a_read_is_answered (every PR) — a head one and a half reads long, written and half-closed before the first read, every event carrying the EOF. The read path took one buffer, found the head incomplete, and closed the slot because the event said the peer had half-closed, with the rest of the head still in the socket; it now closes an incomplete head only after a read of nothing, or a shorter one, behind the FIN. Smoke test a half-closed client sends a 6 KB head fifteen times on both legs (14 of 15 reset on macOS without the fix). Found by lane L of the 2026-10-07 fork review |
| A30 | A client that pipelines and half-closes is answered every request it sent when its EOF is dispatched to a slot whose answer is still going out: the connection closes only after the last request, not after that answer | verified | test_slot_lifecycle.mojo:test_a_half_close_behind_a_slow_answer_keeps_the_pipelined_rest (every PR) — two pipelined requests and a half-close behind a send buffer filled first, so the first answer waits, and the EOF's read event dispatched to the waiting slot. The read path set should_close for the EOF, and the connection closed as that answer landed, the request behind it unanswered. Only a stale event reached such a slot: a slot waiting to write holds no read registration on either backend, so the EOF had to be in the same batch as a pool thread's or the executor's completion that left its answer part sent (--blocking-threads, ASGI); answers the loop ran itself were not affected. Found by lane L of the 2026-10-07 fork review |
| A33 | A listen address names its port: localhost alone is refused as missing its port separator, as 127.0.0.1 alone is, not served on a port the kernel chose; localhost:PORT is the loopback's port, IPv6's under tcp6 |
verified | test_ipv6.mojo:test_an_address_without_a_port_is_refused (every PR) — parse_address read localhost with no colon as the loopback at port 0, so Server.listen_and_serve("localhost") listened on a port no client was told of (review record LF45). Reached by an address a Mojo application passes the server itself; m0serve and the Mojo host join the port they are given to the host. test_ipv6.mojo:test_each_malformed_address_is_refused_by_its_own_rule holds every other refusal in its own rule's words |
| A34 | A listen address's port is ASCII digits naming 0 to 65535: a sign, whitespace or an underscore is refused, not read the way Int() reads it |
verified | test_ipv6.mojo:test_a_port_is_decimal_digits (every PR) — the port was read by Int(), which takes a sign, whitespace around the number and underscores between its digits, so 127.0.0.1:-0 listened on a port the kernel chose and :+80, : 80 and :8_0 on port 80 (review record LF46). Reached by an address a Mojo application passes the server itself; m0serve and the Mojo host write the port they parsed, as digits |
| A35 | URI.parse ends a URL's authority at its first /, ? or # (RFC 3986 §3.2), one rule for the host and the userinfo scan: a query right after the host is the query of /, a fragment there is dropped, and the host and port are read without either |
verified | test_uri.mojo:test_a_query_right_after_the_authority_is_the_query_of_the_root (every PR) — the authority ran to the first / alone, so http://h?q=1 was a host h?q=1 with no query, and http://h:80?q=1 lost its query to the port (review record LF50); test_the_authority_ends_at_a_hash_too holds the #, which the host read ignored while the userinfo scan stopped at it, so http://h#x was a host h#x. An application reaches the parser with its own URLs, its tests building requests with URI.parse; the wire does not, its absolute-form target reduced to a path by the header parse first (B16) |
| A36 | URI.parse reads a port as every byte after the authority's colon, all digits, at most 65535, and an empty one as the scheme's default: :8x, :99999 and :65536 are refused |
verified | test_uri.mojo:test_a_port_is_digits_up_to_65535 (every PR) — the parser read the digits up to the first other byte and narrowed the number to 16 bits, so :8x was port 8, :99999 port 34463 and :65536 port 0, and it refused the empty port RFC 3986 §3.2.3 allows (review record LF51). As for A35, an application's own URLs reach the rule, never the wire: the server's address is the only authority a request from a socket puts in front of its target |
| A37 | A parsed request's URI names the server the same way whatever its target's shape: uri.host and uri.port are the address the server listens on, split once per loop, and uri.request_uri the request target as it arrived (an absolute-form one reduced to its path, B16, and * included, A38); the authority a client names is the Host header's |
verified | test_request_as_sent.mojo:test_the_uri_names_the_server_the_same_way_whatever_the_target (every PR) — they depended on the target: with the loop on 0.0.0.0:8973, GET /x read host 0.0.0.0:8973 and no port and GET /x?q=1 host 0.0.0.0 and port 8973, so a target's query or escape, or an absolute-form target's scheme case, changed what an application read (review record LF54). No server code reads the two on a parsed request: the gateway's SERVER_NAME and SERVER_PORT are its configuration's, and HTTP_HOST is the Host line. The one application that does, apps/request_echo, echoes uri.host for B25's corpus, whose fingerprint leaves it out (it carries the free port of the run). The target is parsed alone, no longer behind the address |
| A38 | A request's writers (encode, write_to) send its target as it arrived or as its URL spelled it, uri.request_uri, escapes and all and * included, never the percent-decoded path |
verified | test_request_as_sent.mojo:test_a_request_is_written_with_its_target_as_received (every PR) — they rebuilt the target from the decoded uri.path and the query: /a%20b went out as /a b, an encoded CR LF in a URL as the bytes themselves, splitting the request line, and OPTIONS * as OPTIONS / (review record LF62). An application that edits a parsed request's uri.path or uri.query_string before writing it out sets uri.request_uri instead; nothing in the tree writes a request to a wire |
| A39 | A request whose handler raises is answered 500 with Connection: close, and its connection closed, whether the loop ran the handler as the request arrived or inline, for a submit batch that could not carry it; a request pipelined behind it is not run |
verified | test_slot_lifecycle.mojo:test_a_handler_that_raises_is_answered_500_and_closed (every PR) — both paths, over a real stream pair: _process_request, with a request pipelined behind the one that raises, and _run_inline. What the handler raised is the application's, and the connection's state after it is not to be trusted. Pinned by the 2026-10-08 audit of the fork's inherited code (A4); sabotaged on each path, each caught |
| A40 | A connection the loop has no slot for is closed unanswered, holding nothing; one whose descriptor cannot be made non-blocking is refused, its slot given back; and a descriptor past the loop's descriptor map (65536 numbers at the start) grows the map and is served | verified | test_slot_lifecycle.mojo:test_admission_takes_what_the_pool_has_room_for_and_maps_any_descriptor (every PR) — _admit_connection at a one-slot pool, a map cut short, and a descriptor closed before it is admitted. Pinned by the 2026-10-08 audit of the fork's inherited code (A4); the close past the last slot and the slot's return each sabotaged and caught |
| A41 | A burst of pipelined requests is answered as the loop reads it: one read per read event, the requests that read brought answered from the buffer with nothing more read and nothing copied behind each answer, and the rest of the socket left to the next event, which a read that filled its buffer registers again for; every request within the caps is answered however large the burst | verified | test_slot_lifecycle.mojo:test_a_pipelined_burst_is_answered_one_read_at_a_time (every PR) — 600 pipelined 27-byte GETs at a receive limit of 8,256 bytes, and 20,000 at the defaults, served only by the events epoll would report (a write one-shot's once the client has read, a read once it has written more or something registered the socket again): every one answered, and no event answering more than one 4 KiB read holds (152). The drain framed each request behind an answer through the read path, whose read came first, so the whole burst came into the buffer during one event and the keep-alive reset copied what was left of it after every answer: 20,000 GETs held the loop 1.56 s in one pass, the cost of a request doubling with the burst, and the 600 were answered twice, then refused 400 and closed (review record LF72). test_slot_lifecycle.mojo's test_a_filled_read_behind_a_pool_thread_is_registered_again holds the registration for a request a pool thread answers, and test_pipelining.mojo's test_keep_pipelined_preserves_tail the reset that copies nothing. Sabotaged five ways, each caught: the read before the parse restored (600 answered in one event), the copy restored, the filled read's spent edge removed, the half-close forgotten at the reset (A27, A30), and the head path's whole-buffer limit restored (C19) |
| A42 | An interim 100 Continue reaches the client whole, ahead of whatever the connection sends next -- the answer, or the refusal that ends the request: what its non-blocking send did not take, part of its 25 bytes or all of them, is sent first, and with a refusal in the refusal's own buffer, so a cut send leaves a truncated stream and never a refusal behind a fragment |
verified | test_slot_lifecycle.mojo:test_an_interim_response_the_socket_did_not_take_goes_out_first (every PR) — over a stream pair whose send side the test fills, so the send takes nothing: the answer, inline and from a pool thread, a 413 for a chunked body over the cap, a 400 for a malformed chunk and the body timer's 408 each follow the whole HTTP/1.1 100 Continue; and a take of 7 of its 25 bytes, the kernel played by the test, leaves the other 18 ahead of the answer. The loop sent it best effort and ignored the count: on Linux a non-blocking send of those 25 bytes came back short in 153 of 200 trials of a loopback TCP socket filled 25 bytes at a time (none in 300 on macOS, which sends a write that small whole or not at all), and the final response then followed a fragment no client can parse; a send that took none of it dropped it (review record LF73). Sabotaged five ways, each caught: the bytes owed not kept, not put ahead of the answer, and not carried by the 413, the 400 and the 408. That the owed bytes and a refusal share ONE send is held by its construction, not by a catch: two sends fail only when the socket gains room between them |
B. Request smuggling (CWE-444)
| id | capability | status | evidence |
|---|---|---|---|
| B1 | Content-Length with Transfer-Encoding rejected |
verified | test_parsing.mojo:test_content_length_with_transfer_encoding_is_rejected (every PR) |
| B2 | Duplicate Content-Length rejected, including across letter case |
verified | test_parsing.mojo:test_duplicate_content_length_is_rejected_across_letter_case (every PR) |
| B3 | Transfer-Encoding whose last coding is not chunked rejected, coding names compared in ASCII |
verified | test_parsing.mojo:test_transfer_encoding_whose_last_coding_is_not_chunked_is_rejected (every PR) — the names were lowercased by Unicode's rules, and chun<U+212A>ed, the KELVIN SIGN in the place of k, was framed as chunked where every other hop reads an unknown coding; the list is split on its bytes, so a member that is not UTF-8 ends at its comma (review record LF58). test_is_chunked_body_asks_the_final_coding_in_ascii holds the loop's own question the same way |
| B4 | Header padding does not bypass the smuggling check | verified | test_parsing.mojo:test_padded_headers_do_not_bypass_the_smuggling_check (every PR) |
| B5 | Bare LF in a chunk extension rejected | verified | test_parsing.mojo:test_bare_lf_in_a_chunk_extension_is_rejected (every PR) |
| B6 | Content-Length integer overflow rejected |
verified | test_parsing.mojo:test_overflowing_content_length_is_rejected (every PR) |
| B7 | Chunk size with the sign bit set rejected | verified | test_parsing.mojo:test_chunk_size_with_the_sign_bit_set_is_rejected (every PR) |
| B8 | h2spec conformance run | out of scope | follows from having no HTTP/2 (A18), the same reason C7 gives |
| B9 | PortSwigger-style desync scanning | out of scope | the scanner probes a proxy/server PAIR for disagreement about framing; this server has no proxy in front of it in any gate, so there is no second parser to disagree with. The shapes it looks for are unit-tested directly above (B1-B7), and fuzzing the decoder itself is G13 |
| B10 | More than one Host field line rejected, on any HTTP version and across letter case (RFC 9112 §3.2) |
verified | test_parsing.mojo:test_a_second_host_line_is_rejected (every PR) — the parser kept the last line and served the request, so a proxy routing on the first Host and an application reading the last (Django's HTTP_HOST) disagreed about the site; two lines that agree are refused too |
| B11 | More than one Transfer-Encoding field line rejected, across letter case |
verified | test_parsing.mojo:test_a_second_transfer_encoding_line_is_rejected (every PR) — field lines combine into one list (RFC 9110 §5.3), so two chunked lines are the chunked, chunked B3's rule refuses on one line, which the last line alone read as a single chunked |
| B12 | A bare LF anywhere in a request head (the request line, a field line, the empty line that ends the head, an empty line before the request line) rejected with 400 | verified | test_parsing.mojo:test_a_bare_lf_in_a_request_head_is_rejected (every PR) — the parser ended a head at an empty line that was a bare LF while the loop framed it by the first CRLFCRLF, so on apps/hello a request pipelined behind a field line's CRLF and then a bare LF got no answer, and a Content-Length after the bare LF was never read, its body answered as a request. Smoke test pipelined requests sends both shapes and requires one 400 and a close. Beside the rule sits a backstop, in the loop's framing decision since B29: a head the parser ends anywhere but its CRLFCRLF frame is answered 400 and closed. The strict parser never reaches it, and Sabotage the decoder invariants the fuzzer checks gates it by reverting the parser's rule with it and without it. With it, the decision stays a refusal and only the parser's own invariant fails (the wire probe passes on it alone too); without it, LF2 as it shipped, the fuzzer's framing invariant fails. Removed alone, it fails that step twice: the first arm is caught by the framing invariant instead of its own, a miss, and the second finds no backstop to remove |
| B13 | A field line opening with SP or HTAB (obs-fold, RFC 9112 §5.2) rejected | verified | test_parsing.mojo:test_an_obs_fold_line_is_rejected (every PR) — the parser took the line as a field with an EMPTY name, which reached a WSGI application as the environ key HTTP_, and the folded text never joined the value it continued; the RFC allows refusing it or replacing the fold with SP, and this server refuses, at the SP, before the line ends |
| B14 | A chunked request body's trailer section held to field lines ending in CRLF: a bare LF, a run of CR, a CR no LF follows, a line with no name or no colon makes the body invalid | verified | test_parsing.mojo:test_a_trailer_line_must_end_in_crlf (every PR) — B5's rule for the chunk header, applied to the trailer. The section skipped any run of CR and took a bare LF, or any line at all, so a last chunk followed by a bare LF ended a body a stricter hop in front still reads as open, and XY in a trailer was served where llhttp and h11 both refuse it. test_a_trailer_line_must_be_a_field_line holds the name and colon, test_a_trailer_split_at_every_byte_still_decodes that each state waits at a buffer's end, and poe sabotage-trailers reverts each of these rules too |
| B15 | An HTTP/1.0 request carrying Transfer-Encoding answered, then its connection closed, whatever its Connection asks (RFC 9112 §6.1); HTTP/1.2 to HTTP/1.9 need Host as HTTP/1.1 does (RFC 9110 §2.5) |
verified | Smoke test pipelined requests (every PR) — an HTTP/1.0 chunked POST asking for keep-alive was de-chunked and kept alive, so the request pipelined behind it was answered too; RFC 9112 §6.1 calls that framing faulty, and the probe requires one 200 and a close. test_parsing.mojo:test_a_later_http1_minor_version_requires_host holds the other half: the Host check asked for minor version 1 exactly, and GET / HTTP/1.2 with no Host was served. test_slot_lifecycle.mojo:test_the_metrics_path_honours_a_requested_close holds this close, and B17's, on the loop's own /__metrics answer, which reset it |
| B16 | An absolute-form target's authority replaces Host (RFC 9112 §3.2.2), its http/https scheme matched in any letter case and its authority ending at the first /, ? or #; an empty authority or a userinfo refused 400 |
verified | test_parsing.mojo:test_an_absolute_form_authority_replaces_host (every PR) — the authority was thrown away and the sent Host kept, so an application routing on Host read a site the target never named; HTTP://h/p was left whole, a path with no leading slash; http://h?q=1 lost its query; and http:///p was served as /p. The sent Host is still required on HTTP/1.1 (§3.2). Three tests beside it hold the scheme's case, the query and the refusals, each failing alone when its rule is reverted; a target that is not UTF-8 (G14) no longer reaches the reduction, refused 400 by the target scanner since B19, which test_an_absolute_form_target_with_bytes_above_ascii_is_refused holds |
| B17 | One Connection field line read as a list of case-insensitive option tokens (RFC 9110 §7.6.1): close anywhere in it closes the connection behind the answer, and an HTTP/1.0 keep-alive anywhere in it keeps one |
verified | test_request_as_sent.mojo:test_connection_is_read_as_a_list_of_tokens (every PR) — the whole value was compared with close, so Connection: close, TE kept the connection alive and the request pipelined behind it was answered; a member must be the token itself, closed closing nothing. Smoke test pipelined requests sends that shape and requires one 200 and a close. Two Connection field lines are one list since B22 |
| B18 | A CONNECT request refused with 501 before any application sees it, and its connection closed |
verified | test_slot_lifecycle.mojo:test_connect_is_answered_501_and_closed (every PR) — CONNECT asks for a tunnel (RFC 9110 §9.3.6), which this server does not implement, yet the request reached the application, and one that answers every method answered it 200: a front end forwarding CONNECT reads a 2xx as an open tunnel, so whatever the client sent next went through unparsed (review record LF36). The 501 is asked after every rule that answers 400, so a CONNECT that is also malformed — a bare LF, two Host lines, Content-Length beside Transfer-Encoding, a Content-Length that is not a digit run, no Host — is 400 as any request is (test_parsing.mojo:test_a_malformed_connect_is_still_400), as B21's lists are; the method name is case-sensitive. test_parsing.mojo:test_connect_is_refused_as_not_implemented holds the parser's half, and Smoke test pipelined requests sends CONNECT with a request pipelined behind it and requires one 501 and a close |
| B19 | A request target outside RFC 9112 §3.2's forms, or holding a byte above ASCII, refused with 400: origin-form opens with /, absolute-form is http or https, * is OPTIONS's alone, and reaches the application as *, and authority-form CONNECT's (B18); a % escape is left to the application |
verified | test_parsing.mojo:test_a_request_target_must_take_one_of_the_four_forms (every PR) — GET p and GET h:80 were served, the application reading a path with no leading slash, and /caf<0xE9> was served where h11 and llhttp both refuse it, the application reading a path that is not UTF-8 (review record LF37). Smoke test the hello server sends four targets holding such a byte and requires a 400 for each. GET * was served too, as h11 and llhttp serve it; RFC 9112 §3.2.4 gives * to OPTIONS alone. An accepted OPTIONS * reaches the application as *, its path and its request target, where both were / and an application could not tell a server-wide OPTIONS from one on the root (review record LF41), and m0serve's root mount takes it, where no mount matched a target without a leading /. WSGI's PATH_INFO and ASGI's path and raw_path are then *, as gunicorn and uvicorn's h11 server hand it on, which no CI step sends: the claim rests on the bridge building all three from req.uri.path (bridge.mojo's environ and scope builders) and on a by-hand probe through bin/m0serve (2026-10-08), where the access log recorded * and each bare application answered it with its own 404, and / with its root; test_request_as_sent.mojo:test_a_server_wide_options_reaches_the_application_as_an_asterisk and test_offload.mojo:test_the_root_mount_takes_a_server_wide_options hold the two halves, and test_views.mojo:test_a_server_wide_options_routes_as_a_path_of_one_segment how the view table routes it: as a path of one segment, so a /:slug route answers its preflight (204 with the route's Allow) and one registered for OPTIONS runs with * as its parameter. A # is accepted as any other visible byte, as h11 accepts it, and reaches the application where it was sent, in the path or the query, never cut away as a fragment (review record LF53, test_uri.mojo:test_a_hash_in_a_target_is_data). The bytes are the target scanner's rule and the forms parse_request_headers'; test_a_byte_above_ascii_in_the_request_target_is_rejected holds the bytes at every offset across the scanner's widths, and that such a target is invalid before its line ends. Every other VCHAR is still accepted, as h11 accepts it: browsers send braces, a vertical bar and a caret unescaped in a query |
| B20 | An empty Host accepted on HTTP/1.1 when the request target names no authority (origin-form, or *), as RFC 9110 §7.2 asks a client to send it; refused beside an absolute-form target, whose authority Host must be, and a missing Host still refused |
verified | test_parsing.mojo:test_an_empty_host_is_accepted_when_the_target_names_no_authority (every PR) — every empty Host was refused with 400, where h11 and llhttp accept it (review record LF38). The application reads an empty Host (WSGI's HTTP_HOST); one routing on it decides what an empty one means |
| B21 | A transfer coding before the final chunked refused with 501 and the connection closed (RFC 9112 §6.1); where chunked is not the final coding — a lone gzip, chunked out of place or twice, an empty last member — the answer stays 400 and a close, RFC 9112 §6.3's MUST, and 400 wins where a list is both |
verified | test_parsing.mojo:test_a_transfer_coding_other_than_chunked_is_not_implemented (every PR) — this server decodes chunked and nothing else, yet gzip, chunked was de-chunked and its body handed to the application still gzipped, where h11 refuses it (review record LF39). B3's shapes stay 400: with chunked not final the body's end cannot be known. test_slot_lifecycle.mojo:test_a_transfer_coding_it_cannot_decode_is_answered_501_and_closed holds the loop's answer, and Smoke test pipelined requests sends a gzip, chunked body with a request pipelined behind it and requires one 501 and a close |
| B22 | Repeated Connection field lines combined into one list, in order, comma-SP (RFC 9110 §5.3), so close on any of them closes the connection behind the answer |
verified | test_request_as_sent.mojo:test_repeated_connection_lines_are_read_as_one_list (every PR) — the header store keeps the last line of a repeated field, so Connection: close then Connection: keep-alive kept the connection and the request pipelined behind it was answered, and an HTTP/1.0 keep-alive before a TE line closed it (review record LF40). Only Connection is combined: it is the list the loop acts on for every request, and a request carries no Set-Cookie, the field that never combines. test_slot_lifecycle.mojo:test_a_close_on_either_connection_line_closes holds the loop's close, and Smoke test pipelined requests sends the shape and requires one 200 and a close. The lines are joined outside the header store and set once, as Cookie's are: set at every line, the list so far was copied into the store's blob each time, which never overwrites, and 98 lines of a 32 KB head left a 1.5 MB blob on the loop thread; test_parsing.mojo:test_repeated_connection_lines_are_joined_in_linear_space holds the blob within the head's size |
| B23 | A request head still arriving that holds a bare LF answered 400 and closed at the read that brings it, before any CRLFCRLF frames it | verified | test_slot_lifecycle.mojo:test_a_bare_lf_in_a_head_still_arriving_is_answered_400_at_once (every PR) — the parser that refuses a bare LF (B12) runs only once find_header_end frames a head by its CRLFCRLF, and a head of bare LFs holds none, so it went unanswered until its peer's EOF or the header timeout's 408 (review record LF40); never served, so no desync. Only the bytes each read adds are scanned, each with the byte before it, so a CR ending one read and the LF opening the next are a CRLF; the scan is one more SIMD pass over the bytes a read adds to a head still incomplete, of the order of find_header_end's own pass over them, and a head that arrives whole is not scanned. test_parsing.mojo:test_a_bare_lf_is_found_in_a_head_still_arriving_at_every_offset holds the scan across its widths, and Smoke test pipelined requests sends a head of bare LFs without half-closing and requires a 400 and a close |
| B24 | A control byte other than HTAB, or DEL, in a chunked body's trailer field value makes the body invalid, as it makes a head's field value (RFC 9110 §5.5) | verified | test_parsing.mojo:test_a_control_byte_in_a_trailer_value_is_refused (every PR) — B14 held the trailer to field lines ending in CRLF but took any byte but LF in a value, NUL and VT included, where a head's value refuses them. The trailer is discarded, so this keeps one rule for field content rather than closing an exposure (review record LF4's deferred minor). poe sabotage-trailers reverts it with B14's rules |
| B25 | The differential corpus: requests that probe framing and field parsing (line endings, folding, field names, the request line, Host, Content-Length, Transfer-Encoding, chunked bodies and trailers, Expect, Connection, pipelining), each sent on a connection of its own and half-closed, are answered as scripts/differential_expected.json freezes them: the requests the application saw, each by its method, its body length and a fingerprint of its target, protocol and header lines, every other response by its status, and the close; and the server serves a plain GET after them |
verified | Smoke test the differential request corpus (every PR) — the fork review's D0 run sent these cases to this server, to h11 0.16 and to Node 24's llhttp (2026-10-07), and where this server was wrong it was fixed (B12-B24). The table freezes its answers since, both references' beside them for reading a row, and a note on each case where this server differs from both on purpose (B11, B16, B18, B19, B27, an incomplete head at EOF). A change to the parser or the loop that moves an answer, or changes which request the application is handed, fails naming the case; one that moves it on purpose re-freezes the row with poe freeze-differential, which asks the references too (their echoes are in scripts/, never a CI dependency). A server that stops closing a half-closed connection fails at the third such case in a row, at 1 s each. No answer is frozen as a known divergence: Host: a b and Host: u@a, served until review record LF64 as both references serve them, are refused 400 (B27) and re-frozen so. The server is apps/request_echo, a test-only echo whose application names every request it is handed |
| B26 | The differential corpus notices a request rule undone: the bare-LF head end (B12, with the check that the two framers agree), the bare-LF field-line end (B12), the obs-fold refusal (B13), the refusal of Content-Length beside Transfer-Encoding (B1), of a second Host (B10) and of a Host value that is no uri-host (B27), the 501 for a coding before chunked (B21), the CONNECT refusal (B18) and the HTTP/1.0 client spared a 100 Continue (RFC 9110 §10.1.1), each reverted alone, fail B25's smoke naming the case that shows it |
verified | sabotage-differential (pre-release) — each rule is held on every pull request by a gate of its own, its row's (A11 for the 100 Continue); this shows the corpus holds it too, so a re-frozen table cannot quietly accept what a rule refuses. The parser's bare-LF head end alone is not caught, and is not meant to be: the loop refuses a head its two framers end at different bytes, so that arm reverts both. Pre-release because each arm builds the echo server again, cold: the first whole run, its baseline and eight arms, took 73 s on the reference Mac (2026-10-08) and caught all eight; B27's arm, added with review record LF64, caught host_with_space run alone |
| B27 | A Host field value that is not uri-host [ ":" port ] (RFC 9110 §7.2) refused with 400: the host a bracketed IP literal, or a reg-name of RFC 3986's unreserved bytes, sub-delims and % escapes (an IPv4 address is one), and the port digits; an empty host before a port refused, and the empty value left to B20. An absolute-form target's authority, which replaces Host (B16), is held to the same shape |
verified | test_parsing.mojo:test_an_invalid_host_value_is_rejected (every PR) — Host: a b and Host: u@a were served, where RFC 9112 §3.2 asks for 400 to a Host with an invalid field value; h11 and llhttp serve both, so an application routing on the name read one no client can have meant (review record LF64). A byte above ASCII is refused, raw UTF-8 included: a reg-name carries a non-ASCII name %-escaped or as punycode. An IPv6 zone ID is refused, deliberately, percent-encoded ([fe80::1%25eth0]) and raw (%eth0, which older CPython http.client builds put in Host): browsers, curl and Go never send one, and RFC 3986's IP literal has none |
| B28 | A chunk header is the size, then a ;-opened extension, whitespace allowed before the ; (RFC 9112 §7.1.1's BWS), or the CRLF: any other byte after the size, and a control byte other than HTAB, or DEL, in an extension, makes the body invalid; whitespace before a bare CRLF is a leniency kept |
verified | test_parsing.mojo:test_a_chunk_size_line_is_the_size_then_extensions (every PR) — the bytes after the size were skipped to the CR whatever they were, so 5 5 was a chunk of 5 where h11 and llhttp refuse it and a parser that strips whitespace reads 0x55, and an extension's NUL or VT was taken where a head's field value and a trailer's refuse them (B24) (review record LF60). Whitespace before a bare CRLF (5 \r\n) is no part of the grammar, whose BWS stands only before a ;: it is still taken, as h11 takes it and llhttp refuses it (B25's chunk_size_trailing_space). Each shape is held fed whole and in pieces of one to five bytes |
| B29 | The event loop frames a request head by one pure decision (frame_request_head): the head end it frames by is where the parser stops over those bytes, or the request is refused; a head split across reads is framed as it is whole; a refusal stays one, and a framed request is unchanged, whatever arrives behind it |
verified | Fuzz the request decoder (every PR) — the loop asked its two framers, find_header_end and the parser, inline, and when they disagreed (B12) the bytes between were lost. The decision is now one function, run over the unmutated corpus fed a byte at a time and over 20k mutations split at random reads, each call told how far the last scanned, as the loop calls it. It found two heads the loop answered differently split and whole. With one to three bytes scanned the terminator search began at them, so two empty lines and then a request, refused 400 whole, were served when a read ended inside the first CRLFCRLF (review record LF66; A3's LF59, the same defect and fix, is its duplicate); find_header_end now backs off three bytes and never before the first, test_slot_lifecycle.mojo:test_empty_lines_before_a_request_are_answered_alike_split_or_whole holds it on the loop, split after one, two and three bytes, and test_parsing.mojo:test_a_head_is_framed_at_its_first_crlfcrlf_whatever_the_resume_point holds the search at every terminator offset from every resume point. And the parser skipped a bare CR before the request line as if it were an empty line, reading the method from the byte after it, so a lone CR before GET was served whole and refused split (review record LF67); RFC 9112 §2.2 makes a bare CR invalid, and it is refused at the byte after it (test_parsing.mojo:test_a_bare_cr_before_the_request_line_is_rejected). Sabotage the decoder invariants the fuzzer checks reverts LF66's fix and drops the byte before a read's new bytes from the bare-LF scan, each failing the split invariant, and reverts LF2 twice, as B12 says. LF67's fix is its unit test's alone: the split invariant caught its revert only through is_complete, a rescan of a head already framed, and with that gone (review record LF61) a split read and a whole one take the same path, so its revert serves both alike and the fuzzer has no difference to see |
| B30 | A chunked request body fed to its connection's one decoder a read at a time decodes as it does whole (the same answer, the same body, the bytes behind it kept for the next request), and a body the decoder completes ends every framing line in CRLF, never a bare LF | verified | Fuzz the request decoder (every PR) — a walker of the fuzzer's own reads each completed body line by line against its decoded bytes, and the decoder is fed in the loop's layout, [decoded][pending][new], at random reads and a byte at a time, over the mutations and over the body of each request the framing decision frames as chunked (B29). Sabotage the decoder invariants the fuzzer checks ends a body at a bare-LF empty trailer line (B14's defect, which poe sabotage-trailers puts back against the unit tests) and refuses a read that ends just after a chunk's data, and each fails its invariant |
C. Connection management and denial of service
| id | capability | status | evidence |
|---|---|---|---|
| C1 | Max request body size, configurable by --max-body or M0_MAX_BODY, the flag winning; the first 413 a loop sends prints one line naming the flag, the variable and the cap in effect, and --doctor reports the cap the server would use |
verified | Smoke test the serve CLI (every PR) — --max-body 1k, 8m and the default, each refusing above and admitting below; M0_MAX_BODY=1k with no flag the same, under two spawned workers; the notice once after several refusals, carrying 1k; and --doctor's max_body at the default (4194304, where it printed -1), from the environment and under a flag that beats it. A size too large for an Int (17179869184g, which wrapped to a 0-byte cap) is refused rather than wrapped: exit 2 as a flag (test_cli.mojo:test_max_body_too_large_for_an_int_is_refused_not_wrapped), and from the environment ignored out loud with the 4 MB cap standing (test_cli.mojo:test_from_env_ignores_a_body_limit_too_large_for_an_int_and_says_so) |
| C2 | Chunk size limited to 16 significant digits | verified | test_parsing.mojo:test_chunk_size_is_limited_to_sixteen_significant_digits (every PR) |
| C3 | Raw chunked bytes bounded independently of decoded size | verified | Smoke test the serve CLI (every PR) — a decoded-legal body costing 2x the cap in framing answers 413 |
| C4 | Max header count | verified | test_parsing.mojo:test_header_count_is_capped (every PR) |
| C5 | A slow handler does not stall connections behind it | verified | Smoke test the blocking-threads pool (every PR) |
| C6 | Connection/request rate limiting | out of scope | a proxy's job; this server has no notion of a client identity to limit on |
| C7 | HTTP/2 reset-flood limits | out of scope | follows from having no HTTP/2 |
| C8 | A burst of new connections does not hold the event loop away from the connections it already serves: one pass admits at most ACCEPT_BATCH (16) of them — off the listener, or off a sibling's accept-share channel — AFTER it has served the events of the connections it holds, and what a batch leaves is owed to the next pass, whose wait does not block, because both listeners are edge-triggered and nothing announces the same backlog twice; the graceful drain passes every connection a sibling handed over on to a sibling that has not left, and admits it only when none is (E16), and the loop inversion takes owed batches inside its callback up to the old bound. M0_ACCEPT_BATCH overrides the batch, 0 taking the whole backlog in one pass as the loop used to (an A/B knob). Before: epoll reports no backlog depth, so its drain ran to EAGAIN, through connections that arrived during it, up to max_connections — and every admission is the connection's eager read, on a loop that runs func itself the whole request |
verified | Smoke test the accept batch (every PR) — apps/pool_spike with func on the loop: a 600 ms request parks the loop, 120 connections queue behind it each carrying a 10 ms request, and a keep-alive connection already established sends /fast. No bound is in nominal milliseconds, because a runner's timers oversleep (two of CI's macOS runs failed one with the loop behaving). /fast must be answered within one and a half batches of what one such request COSTS on the box, measured first and counted from the blocker's own answer (the default 16, which the probe reads from the constant; 153 ms measured on Linux against about 256, the rest of the one batch the blocker itself was admitted in), with all 120 answered (a batch stranded behind the listener times out) and no two answers more than 500 ms apart: half the loop's one-second wait, which a wait that blocked between owed batches runs to (1012 ms measured), where the widest honest gap is one request (11 ms on Linux; 54–100 ms on the macOS runner, whose per-request cost moved from 17 to 49 ms inside one run, so the burst's total is printed, not bounded). That bound holds the batch on Linux only: on macOS kqueue reports a backlog of one when the blocker arrives, so the blocker's batch is the blocker alone and /fast goes first with or without a cap (0–1 ms after the blocker in every macOS CI run of 2026-09-28 and 29). So on both legs the same round runs again with the server STOPPED while the blocker and the burst queue, and there the batch is COUNTED, not timed: between one and one and a half batches (24) of the burst answered before /fast, 15 measured on both, the blocker's batch less the blocker — an order no drift in what a request costs can move. With M0_ACCEPT_BATCH=0 the same stopped round must put at least two thirds of the burst before /fast (all 120 measured, on both legs) — the arm that proves the probe sees the old drain. Sabotaged by hand before landing — no cap, nothing owed, a wait that blocks while a batch is owed, the batch taken where the listen event falls — each caught by its own check, recorded in the note with the throughput measured beside it; the counted arm fails on both legs with the knob at 0 and with the cap removed from the loop, 120 before /fast against 24. The design is the-accept-batch |
| C9 | A client that resets its connection gives its slot back at once, whether its response is stalled on a full socket, in memory or a --static file, or the connection sits idle between requests: the send or recv the reset wakes returns the error, and the slot closes |
verified | Smoke test a client that resets its connection (every PR) — m0serve with --idle-timeout 0, so no deadline can give back a slot a reset leaves behind, and --metrics: each of the three shapes is reset with SO_LINGER 0, and http_active_connections must be one higher while the connection is open and back to its earlier count within 3 s of the reset. Fails without the fix on Linux only (review record B12): epoll reported EPOLLERR as EV_ERROR, which the loop skips as kqueue's failed registration, and the one-shot write or edge-triggered read the RST arrived on was spent with it. Measured in the Linux container before the fix, every shape held its slot, and a stalled file response still held the slot and both its descriptors 20 s after the reset, while SIGTERM took the full 5 s drain (0.04 s after). kqueue reports a reset as EV_EOF, so on macOS this passes either way |
| C10 | A connection that is not being read costs the loop nothing: a slot waiting to write -- its response stalled on a client that is not reading, in memory or from a --static file -- or whose request is out on a pool thread or an executor holds no read interest, so a client that half-closed, or sent its next request, and then stopped reading is not reported again until the slot reads, and what it sent is answered then |
verified | Smoke test connections that stop reading (every PR) — scripts/slot_lifecycle_probe.py meters the server's CPU for 2 s in four shapes, each built so the loop has armed its read interest before the request arrives: a 16 MiB echo stalled on its client, which then half-closes; a 16 MiB --static file with the next request pipelined behind it, unread; and a request on the executor, half-closed and pipelined. Each must stay under half a CPU second, and everything the client asked for must then arrive whole. A spinning child must read as spinning first, so the meter is checked before it is trusted. Fails without the fix on macOS only (review record R4): kqueue's read filter is level triggered, stayed registered beside the write one-shot and was never removed for an offloaded request, so every wait reported the same bytes or EOF — 1.97–2.01 CPU seconds in 2 in each shape, against 0.00 for a request on the executor with nothing pending; the write one-shot now drops the read filter in the same kevent call, and an offloaded slot's read event drops it until the completion. Each half sabotaged alone fails its own two shapes. epoll's write one-shot replaces its read interest and its reads are edge triggered, so Linux passes either way. The backend contract is also test_slot_lifecycle.mojo:test_a_write_wait_holds_no_read_interest, against the real multiplexer on each OS |
| C11 | A connection's timers are its own at any descriptor number: on epoll each timer's timerfd is kept at fd * 5 + kind in a map that grows with the descriptors timers are set for, so a descriptor at or above 65536 takes no other timer's slot |
verified | test_epoll_timers.mojo:test_every_timer_has_a_slot_of_its_own_at_any_descriptor (every PR) — the slot arithmetic, on both legs; test_a_high_descriptors_heartbeat_leaves_the_app_tick_alone and test_a_high_descriptors_body_timer_is_its_own drive the platform's backend and bite on the Linux leg only, kqueue keying a timer by its ident. The map was five regions of 65536 slots, one per kind (review record LF18): descriptor 65536's SSE heartbeat took the app tick's slot, re-arming its timerfd and, when the stream ended, closing it, so the tick stopped for good; descriptor 131072 + k's body timer took descriptor k's heartbeat; and a heartbeat at or above 131072, or a body timer at or above 262144, fell off the map and was never set. The slot test fails with the old arithmetic over the new map (body 65536 and idle 0 share slot 131072). Run in the m0lin Linux container (aarch64), the backend tests pass, and with the old arithmetic restored both fail: the app tick never fires (-1 against 5242880), and descriptor 5's heartbeat (4194309) fires in place of descriptor 131077's body timer (2228229). A descriptor at or above 2^20 cannot be told from a lower one by its ident (loop/state.mojo's encoding); a process reaches one only with RLIMIT_NOFILE above 2^20 |
| C12 | A listener's accept queue holds up to SOMAXCONN connections (4096 on Linux, 128 on macOS), or the system's setting if that is lower: ListenConfig.listen asks for the platform's SOMAXCONN, which the kernel clamps to net.core.somaxconn or kern.ipc.somaxconn, so a burst of new connections arriving while the loop is busy queues past 128 on Linux rather than overflowing |
verified | test_listen_backlog.mojo:test_the_listener_asks_for_the_systems_backlog (every PR) — on the Linux leg the listener's limit as TCP_INFO reports it (tcpi_sacked, what ss -lt shows) must be min(SOMAXCONN, net.core.somaxconn); macOS's limit is 128 either way (netstat -Lan shows it as maxqlen), so there the test holds the constant. The listener asked for 128 (review record LF19), and Linux, whose default somaxconn has been 4096 since 5.4, drops each handshake past a full queue (TcpExtListenOverflows), its client retrying a second later. No knob, by the owner's decision. Run in the m0lin Linux container (aarch64, somaxconn 4096) it passes, and with listen(128) restored it fails, the limit read back as 128 against 4096; the overflow itself was not measured |
| C13 | A loop's backend gives back what it opened when it is destroyed -- its kqueue or epoll instance, and on epoll every timerfd it still holds -- and an epoll registration the kernel refuses is reported as itself | verified | test_backend_fds.mojo:test_a_destroyed_backend_closes_what_it_opened (every PR) — a backend that armed two timers is let go, and the process must hold exactly the descriptors it held before; with the kqueue backend's close removed it fails naming the multiplexer still open. Neither backend closed anything (review record LF21): every Server.serve_nonblocking that returned, and every loop thread of the host's that ended, left its multiplexer open for the life of the process. test_epoll_reports_the_registration_that_failed holds the other half on the Linux leg: add_read fell back from ADD to MOD on any errno, so an ADD refused with ELOOP was reported as MOD's ENOENT; it falls back on EEXIST alone, and add_write_oneshot from MOD to ADD on ENOENT alone. Run in the m0lin Linux container (aarch64) both pass; with the any-errno fallback restored the second fails on MOD's No such file or directory (errno 2), and with the epoll destructor's timerfd closes removed the first fails, 9 descriptors open against 7 |
| C16 | A loop's backend reports what it was asked to watch, on each platform: a readable descriptor is one read event naming it; bytes left unread are reported again on kqueue (level triggered) and not on epoll (edge triggered), and a re-add reports them on both; a deleted read interest reports nothing; a one-shot write fires once; a timer fires once with its ident, and one deleted first never fires; a writer that has gone is EV_EOF on the reader; and a wait in nanoseconds waits | verified | test_platform.mojo:test_the_backend_reports_what_it_was_asked_to_watch (every PR) — the kqueue and epoll wrappers under the loop are inherited code the loop's own tests reached only through whole requests (review audit A3). Over a pipe, which both multiplexers watch; on macOS on the Unit tests job's macOS leg, epoll on its Linux one |
| C17 | A buffer a large request or response grew past 64 KiB (or past the configured read size, if that is larger) is not pinned to its slot: when the connection closes it goes back to the allocator, and connections on any slot reuse it, so repeated bursts of uploads on fresh slots level off rather than adding a burst's worth each time; the slot's next connection starts from one read, as a slot's first does, and buffers no larger than that stay with their slot. Whether freed memory goes back to the system is the allocator's to decide | verified | Smoke test that a closed connection's buffers serve other slots (every PR) — scripts/slot_buffer_probe.py holds 64 keep-alive 4 MiB uploads open on apps/hello, whose footprint (macOS's physical footprint, read through proc_pid_rusage; VmRSS plus VmSwap on Linux) must rise at least 128 MiB, the control that the buffers grew; it closes them, then three times fills every slot used so far with connections that complete one small request and idle, and lands 64 more uploads on slots never used. The footprint above its start may never pass 2.5 times what the first round cost. Before the fix (review record LF25) a closing slot cleared its buffer, which kept the capacity, and a slot is never rebuilt: 64 uploads left apps/hello at 431-448 MB resident against 14 MB after 64 small requests, and each round on other slots added about 400 MB, 3.62-3.88 times one round after three; with it, 1.12-1.40 on macOS, where the allocator keeps what is freed for reuse, and 0.10 on CI's ubuntu leg the first time the smoke ran there (train #597), the freed buffers having gone back to the system. One burst's resident size is not the measure: the allocator (TCMalloc, in Mojo's runtime on macOS) keeps what is freed, and 283-309 MB stayed resident after one burst with the fix, reused by the next. test_slot_lifecycle.mojo:test_a_closed_slot_gives_back_a_grown_buffer holds each buffer's capacity at the close, with test_a_closed_slot_keeps_an_ordinary_buffer and test_a_closed_slot_keeps_buffers_within_its_configured_size beside it. Sabotaged four ways (no shed, which fails the smoke and the test; the encode buffer's shed alone removed; the threshold dropped to zero; the threshold blind to the configured size), each caught |
| C18 | A request body read after its head is held to its own sizes, never to the receive buffer, which holds the head and the next pipelined request beside it: a Content-Length body to the cap the framing checked, a chunked body to its decoded size and its raw cost (C3) after each decode. So a body within both caps is answered whatever shares the reads that bring it after its head, and one over the body cap is refused 413, with the lingering close (A20), whatever its head's size. A head is held to recv_buffer_limit() on its own, while it is incomplete (C19, review record LF72) |
verified | test_slot_lifecycle.mojo:test_a_body_read_after_its_head_is_measured_by_its_own_sizes (every PR) — at a 128-byte head cap and a 64-byte body cap with no floor under the buffer, each body read after its head: a Content-Length body at the cap whose last read brings the next request (both answered, where it was 400 and a close), a chunked body at the cap (answered as it is with its head, where it was 413), a chunked body under the cap with the next request behind it (both answered, where it was 413), and a chunked body over the cap behind a 120-byte head (413 and lingered, where it was 400 and a close). The body path compared the whole receive buffer with recv_buffer_limit(), and measured a chunked body before decoding it as its decoded part plus the raw tail still buffered (review record LF70). Sabotaged three ways, each caught by its own assertion: the buffer comparison restored, the pre-decode measure restored, the post-decode bound removed. With no buffer compared, a chunked body's memory rests on its decoder leaving nothing undecoded while the body is incomplete, so a line that never ends is counted, not kept: test_a_chunk_line_that_never_ends_is_refused_at_twice_the_cap sends an extension that never reaches its CR, and a size line of leading zeros that never ends, a read at a time, and requires the buffer to hold the head alone after each read and a 413 with the lingering close by the read past twice the cap; a decoder that keeps an unfinished extension back fails it |
| C19 | A request head is held to recv_buffer_limit() alone, from its first byte, and only while it is incomplete: what arrives with it -- its body, that body's chunk framing, a request pipelined behind it -- never counts against it, so a request within every cap gets the same answer however its reads fall |
verified | test_slot_lifecycle.mojo:test_a_request_is_answered_alike_in_one_read_or_two (every PR) — at a 128-byte head cap and a 64-byte body cap with no floor under the buffer (a 192-byte limit): a Content-Length body at the cap with a request behind it (200 and 200), a chunked body at the cap behind a 120-byte head (200), and a chunked body over the cap (413, with the lingering close of A20), each sent in one read and in two; twenty GETs in one 540-byte read (all answered); and a head of 223 bytes with no end (400 and a close). The head path compared the whole receive buffer with the limit, so each one-read shape was refused 400 and closed, and the twenty GETs with it (review record LF72, the head-path half of what LF70 found in the body path). Sabotaged: the whole-buffer comparison restored, caught at the first one-read shape |
D. Graceful shutdown and restart
| id | capability | status | evidence |
|---|---|---|---|
| D1 | SIGTERM drains in-flight requests | verified | Smoke test graceful shutdown (every PR) — including a request whose event is ready in the same batch as the stop, behind it: the pass that reads the shutdown pipe handles its whole batch, and admits no new connection. It stopped at the pipe, and on Linux, where such an event is reported once, a request whose handler raised SIGTERM on the ASGI executor went unanswered while the drain waited out its 5 s, 20 of 32 tries with the server on one CPU (review record B22). test_shutdown_pass.mojo puts a read, a completion, a write, a bus frame and a timer behind the stop over a backend that reports each once, and smoke-asgi sends that request on the pump, eight times pinned to one CPU where taskset exists. The drain stops watching the listener before it closes it, and forgets the number: on Linux a closed descriptor that another process still holds stays in epoll's interest list, so a worker draining under --workers N went on waking for connections waiting for its siblings to accept them, and a connection handed over during the drain that took the listener's freed number had its reads taken for the listener's, its request never read and the client reset when the budget ran out (review record B24). test_drain_listener.mojo checks the order of the two steps, reads a connection given the old number, and on Linux asks the kernel whether the closed listener still reports. The drain's close is the listener's only one: the loop owns the listener it is given, and every owner hands it over (into_fd) where each used to keep one that closed the number again once the loop returned -- Server.listen_and_serve and serve, the Mojo host, m0serve with and without a pool -- when the drain had left it free and something else in the process had usually been given it (a print's dup(1), pool threads' .pyc files), so the second close failed or closed a descriptor that was not the owner's (review record B26). test_listener_owner.mojo holds the number past the return of the fork's Server, the Mojo host and the loop's own error paths, and on Linux the step traces m0serve's inline, pooled, executor, inverted and spawned-worker listeners under strace. A socket that has closed its number leaves it alone after that: Socket.close() marks it unconnected, so one made from a descriptor it was given (Socket(fd=...)), which is born connected, no longer shuts the number down when it is destroyed, and a second close() does nothing, where each reached whatever had taken the number by then (review record B27, latent: since B26 no owner in the tree closes a socket and keeps it). Nor does anything else it is asked to do: close() leaves it holding -1, so send, receive, bind, listen and its socket options are refused with EBADF, shutdown does nothing and into_fd hands over -1, where each acted on the descriptor that had taken the number -- writing into it, taking its bytes, shutting it down, binding it, making it listen, setting its options, or giving it to an owner that closed it (review record B27b, latent likewise): test_socket_close.mojo, a test per method, each putting another socket on the freed number first. A close() that fails with an errno other than EBADF leaves the socket closed too, the failure raised: Linux and macOS release the number before anything that can fail, and the socket used to raise still holding it, so a second close() or its destructor closed whatever had taken the number (review AR, after B27b): test_socket_close.mojo:test_a_close_that_fails_leaves_what_took_its_number_open, EINTR and EIO through a DescriptorClose that releases the number and then fails, caught with the old order restored. Under accept sharing the drain also waits, within its budget, for a hand-off a sibling counted to it before it left, and passes what its channel delivers on to a sibling that has not left rather than admitting it, where it ended as soon as nothing was in flight and closed a hand-off that had not yet sent its first byte (review record B25, E16; test_handoff_leaving.mojo) |
| D2 | SIGTERM to the supervisor alone reaps workers | verified | Smoke test graceful shutdown (every PR) — the supervisor alone is signalled once its workers serve, and again while it is still forking them: M0_TEST_FORK_GAP_MS holds it between its first fork and its second, and it must pass the signal on, fork no more and exit 0 with nothing of its process group left. It armed that handler only after its last fork until 2026-09-29, and a worker answers before the next is forked, so a SIGTERM in between killed the supervisor and orphaned the workers (CI, once in about a hundred runs). test_respawn.mojo holds the two moments too short to hit from outside: a worker the stop reaches before it restores its own signals leaves at once, signalling no sibling, and a stop that lands before a worker's PID is published still reaches it. The first phase signals only once every worker has printed armed for a graceful stop, and requires both to report a clean exit and neither to die of the signal: it signalled once worker 0 answered, and CI's run once reached worker 1 at SIGTERM's default action, before it had armed (review S1). M0_TEST_ARM_GAP_MS holds every worker but the first unarmed for 2 s after its fork, so that wait is exercised on every run. m0serve's workers print the same line once armed, after the application's import (review AR; signalled at the first answer instead, a sibling still importing died of the signal in 5 to 19 rounds of 20 against the bare app on macOS), and a phase holds m0serve --workers 2's worker 1 the same way, signals its supervisor once both lines are in, and requires both workers' clean exits and neither's death by the signal. A worker killed by the stop before it armed is judged a stop, not a failure (test_respawn.mojo:test_an_unarmed_death_beside_a_clean_drain_is_a_clean_stop) |
| D3 | Bounded drain, naming what it abandoned | verified | Smoke test streamed WSGI bodies (every PR) |
| D4 | SIGTERM reaches a shutdown pipe rather than killing the process | verified | test_lifecycle.mojo:test_sigterm_reaches_the_shutdown_pipe (every PR) |
| D5 | Development hot reload on file change | verified | Smoke test hot reload (every PR) — --reload, --reload-dir. A reload forks a replacement into every index, one the supervisor had given up on included, and the polling loop then counts what it supervises from the live pids and the give-up is over: test_respawn.mojo:test_a_reload_refills_an_index_the_poller_gave_up_on_and_counts_it — two workers, no respawns, worker 1 crashed and given up on, an edit, worker 0's clean exit, and the supervisor must still be supervising the replacement and exit 0 once it has gone; before (review record 605) its count stood at one through the reload, so it left with the replacement serving on, an orphan, and exited 1 |
| D9 | A request whose body is still arriving at SIGTERM is read on and answered, not left to the deadline | verified | Smoke test the drain with an upload in flight (every PR) — scripts/drain_upload_probe.py, two-sided: answered whole AND exited inside 3 s, on the ASGI executor and on a WSGI pool thread |
| D10 | A worker that fails its drain — exits non-zero, or dies of a signal other than the one forwarded — after the supervisor was told to stop is not respawned; the supervisor exits 1 once the rest are gone, rather than serving a replacement nothing will ever signal | verified | test_respawn.mojo:test_a_worker_that_fails_while_stopping_is_not_respawned (every PR) — a supervisor signalled alone forwards SIGTERM, its worker exits 9 from the drain, and the test requires exit 1 and no second incarnation; with the guard reverted the worker is respawned and the supervisor exits 0. Found by sabotage-host: a host worker returning from main crashed on its way out, was respawned, and its supervisor outlived the smoke holding the port (the note: the-mojo-host). The stop is recorded by the signal handler in a data-segment word beside the child PIDs (src/global_slot.mojo). It holds whichever worker is reaped first: when that was a worker killed by the forwarded SIGTERM before it armed its handler, the supervisor passed the signal on and reaped the rest without judging them, so a sibling failing its drain went unseen and the exit was 0, under _supervise and --reload's polling loop alike (review S1). test_a_sibling_failing_its_drain_after_an_unarmed_death_makes_the_exit_1 and test_the_polling_supervisor_judges_the_rest_after_an_unarmed_death require 1 there, the second for a sibling killed by SIGKILL |
| D11 | An ASGI application's work still running at shutdown is bounded: background work gets the drain's 3 s grace, is then cancelled and named, and lifespan shutdown still runs | verified | Conformance test the ASGI bridge (every PR) — a response whose background task never ends, then SIGTERM: the process must exit and write the lifespan shutdown marker. Before, the post-pill gather waited on every HTTP task, the 5 s thread join gave up, and the process left without shutting the application down (exit 0, no marker). poe test-shim holds the rest: work inside the grace finishes, a task that swallows its cancellation is left behind after half a second and never reaches the executor again, and the drain starts once |
| D12 | A chunked response stream open at SIGTERM ends with its terminator or EOF, never with bytes the client's chunked parser cannot read | verified | Smoke test graceful shutdown (every PR) — m0serve on the bare ASGI app, /stream-forever read through a strict chunked parser, SIGTERM, then the parser to EOF, and m0serve exiting 0 (scripts/drain_stream_probe.py). The drain's farewell wrote an SSE close comment, : close, into the chunked stream raw, where the client reads a chunk size. Found by the 2026-10-07 fork review |
| D13 | The drain's farewell to streams goes only where a heartbeat may: an idle SSE stream gets its close comment and an idle WebSocket a Close carrying 1001, and a WebSocket that has sent its Close, a stream with a frame half sent, and an SSE stream its application writes through the chunk channel are closed as they stand | verified | test_slot_lifecycle.mojo:test_the_farewell_writes_only_into_a_stream_that_takes_one (every PR) — the five shapes over stream pairs, each read to EOF. The farewell asked none of the heartbeat's questions: a second Close, bytes inside a frame, and a comment in a chunked or chunk-channel stream. One predicate, _takes_an_out_of_band_frame, now answers for both. Found by the 2026-10-07 fork review |
| D14 | The drain waits only for requests whose head has arrived: a connection holding none when the drain begins, or after any of its passes — between requests, a stray empty line after its last one, or the start of a head that has not wholly arrived — is closed, its answers delivered first, and a body still arriving is read on (D9). Until #611 a keep-alive connection holding a stray CRLF or the first bytes of its next request held every drain to its 5 s budget | verified | test_drain_unstarted.mojo:test_the_first_bytes_of_a_next_request_are_no_request (every PR) — beside it, an empty line after an answered request closed with the answer delivered, the rule over a table of tails read from head_start, and a body still arriving kept and answered; with the old empty-buffer test restored, the first two fail. Measured with the issue's reproducer against m0serve on macOS: 5.1 s from SIGTERM to exit with a stray CRLF or the start of a request buffered, 0.04 s with the fix |
| D15 | A drain its budget ends says what it leaves: one line counting the connections still open by what each was doing — a body still arriving, a request being answered, a response still sending, a stream — and a drain its work ends prints nothing | verified | test_drain_unstarted.mojo:test_the_deadline_names_what_it_leaves (every PR) |
| D6 | SO_REUSEPORT off by default: a second bind fails loudly instead of silently sharing the port |
verified | Smoke test the serve CLI (every PR) — five attempts a second apart, so a restart racing the previous process's drain still succeeds; the second server must exit 1 naming the address, print no ready banner, and leave the first answering. The opt-in ListenConfig.reuse_port remains for a deliberate handoff between two processes that both mean to listen; no shipped path enables it, because workers and threads all accept from ONE listener bound before the fork. test_ipv6.mojo:test_a_listener_shares_its_port_only_when_asked reads the options off the socket: SO_REUSEADDR set, SO_REUSEPORT only under reuse_port |
| D7 | Binary/hot upgrade (USR2-style overlap) | out of scope | needs socket handoff this server does not have; run two behind a proxy |
| D8 | SIGHUP reload | out of scope | --reload covers development; production reload is a new process behind a proxy |
E. Process and worker model
| id | capability | status | evidence |
|---|---|---|---|
| E1 | Multi-process prefork with a supervising parent | verified | Smoke test the Django WSGI example (every PR) |
| E2 | Crashed workers respawned | verified | test_respawn.mojo:test_respawned_worker_returns_to_the_callers_startup_path (every PR) |
| E3 | A spent respawn budget exits nonzero rather than looping | verified | test_respawn.mojo:test_supervisor_exits_nonzero_when_respawn_budget_is_spent (every PR) |
| E36 | The respawn budget is a rate, not a total: at most workers × 10 respawns in any hour, so a server whose workers crash rarely replaces every one for as long as it runs, while a crash loop the rapid-crash breaker cannot see (a worker that serves past its first second, then crashes again) still spends the window and ends in E3's exit 1 |
verified | test_respawn.mojo:test_a_budget_spent_over_a_longer_window_is_not_spent (every PR) — two respawns allowed in any 1.5 s and three crashes 1.1 s apart: the third is replaced and the supervisor exits 0. Counted over the supervisor's life, the budget ran out at the third, max respawns (2) reached, and the exit was 1 (measured before the fix): a server whose four workers crashed once a day stopped replacing them after about forty days, and served on fewer, with no error, until none was left. The other direction is test_respawn.mojo:test_a_budget_spent_inside_the_window_is_spent, the same crashes inside the hour spending it and exiting 1. sabotage-host (pre-release) counts the budget over the supervisor's life and leaves a respawn out of the window, each caught by its test (review RB) |
| E40 | The worker model's process calls read a wait status as POSIX lays it out -- the exit code in bits 8-15, the signal in bits 0-6, the core-dump bit apart -- and answer what they cannot do: fork raises at the process limit, waitpid_blocking raises with no child, waitpid_nonblocking answers (-1, 0) with no child and (0, 0) beside one still running, kill_process answers False for a pid that names no process, and create_shutdown_pipe raises at the descriptor limit |
verified | test_process.mojo:test_each_process_call_answers_what_it_cannot_do (every PR) — each failure provoked in a child of the test's (lowered RLIMIT_NPROC; root, which Linux exempts from it, skips the fork check), reported by the child's exit code; test_a_wait_status_is_read_as_posix_lays_it_out reads literal statuses (exits 0, 1, 78 and 255; signals 9 and 11, the second with its core bit) and real ones (a child that exited 255, one SIGKILL ended), and test_a_shutdown_pipe_past_the_descriptor_limit_is_refused lowers RLIMIT_NOFILE. test_respawn.mojo drives the same calls through a supervisor's real forks. Pinned by the 2026-10-08 audit of the fork's inherited code (A4); each answer sabotaged and caught by its own assertion |
| E4 | Handler thread pool behind each event loop | verified | Smoke test the Mojo handler pool (every PR) |
| E5 | Free-threaded CPython, N loops on N threads | verified | py-canary (weekly) |
| E6 | A GIL-enabled interpreter is refused, never warned-and-run | verified | Smoke test the threaded mode's guard (every PR) |
| E7 | Zero-config topology defaults | verified | Smoke test --doctor against the server's own exit codes (every PR) |
| E8 | Max-requests worker recycling with jitter | out of scope | the leak it mitigates is measured instead — smoke-django fails on RSS growth over 10k requests |
| E10 | A worker's configuration refusal (exit 78) ends supervision with 78, never a respawn loop | verified | test_respawn.mojo:test_a_worker_refusing_its_configuration_is_not_respawned (every PR) |
| E11 | The event loop holds no thread state while it serves; a pool thread that drops the GIL hands it to the thread parked waiting for it | verified | Probe the handler pool's GIL hand-off for fairness (every PR) — on the Linux runner, in a job of its own: five pool threads under a 0.65 ms CPU-bound view at twenty connections must answer in job order, at most 5 requests a run passed over by more than 100 requests sent after them and none by more than 1000 (twenty runners of five CPU types, 50 runs an arm: 0, the most 10–22; a 4-vCPU KVM guest: 0, the most 46–68). The same run with the barrier disabled must not (7–116 a run, the most 2596–17892; the guest 17–32), which is what proves the probe can see the failure it guards, and neither may the run with the keep rule disabled, E34's arm (29–93; the guest 73–100). The load is chosen for that arm: with the keep rule off, who starves depends on how many jobs a 1 ms slice holds against how many threads wait, and a 0.65 ms view is two jobs a slice on every machine measured, where the first load's 0.3 ms view sat on the boundary between three and four, and each machine's own overhead decided whether it starved (every AMD EPYC 7763 runner and the guest did; no Intel Xeon or AMD EPYC 9V45 one). The fair arm records a request's share of the GIL against the 1 ms edge of that window. M0_FAIRNESS_EXPECT_STARVATION=0 skips the keep arm, for a machine that answers it in order; none is known. Order rather than latency, because a pause of the whole process delays every connection at once and passes nobody over: the latency bounds it judged first (a p99 under 25 ms, a max under a quarter second) failed five runs of five with the server stopped for 300 ms twice, where order passed all five, and sat near the old shapes' figures from the other side. The reference Mac runs it before a release (docs/RELEASING.md). The design: fairness-judged-by-order; the throughput half of the change is the detached-loop A/B in docs/notes/detached-loop.md |
| E34 | Inside its hand-off slice a pool thread takes a job already queued on its lane without dropping the GIL (OffloadPool.try_next_job: next_job's pill check, socket poll and ring pop, never its spin or park), so the only drops a slice makes are its hand-off and the view's own. Each drop between jobs used to wake a parked waiter that found the GIL taken again and queued behind the others, so the slice's hand-off went back to the thread that had just held it: two threads alternating while two starved, 0.3–1.6 s at a time on 4-vCPU Linux under a CPU-bound view. Only with the barrier on (a pool of one and M0_POOL_TURN=0 are unchanged); M0_POOL_TURN_KEEP=0 drops the GIL between every job again (an A/B knob) |
verified | test_blocking_pool.mojo:test_a_slice_takes_the_jobs_queued_behind_it_without_dropping_the_gil (every PR) — 64 jobs through four threads whose views hold the GIL, every thread awake: all answered, some taken inside a slice, none under the knob (on a free-threaded interpreter, where nothing waits for a GIL, only the answers). Beside it in test_offload.mojo: test_try_next_job_takes_what_is_queued, test_try_next_job_answers_an_empty_lane_at_once (on a thread of its own, so a version that waits fails inside a second rather than hanging the suite) and test_try_next_job_answers_the_pill. The fairness itself is E11's probe, whose keep arm runs with the knob on every PR, at a load where the rule off starves a waiter on every machine measured (twenty runners of five CPU types: 29–93 requests a run passed over by more than 100 later answers, where a fair run has none). Each rule sabotaged by hand and caught by its own test. The design and the measurements: a-slice-keeps-the-gil |
| E9 | Worker lifetime / max-RSS recycling | out of scope | same reason as above |
| E12 | The OS multiplexer is chosen in one place: lightbug_http/c/platform.mojo names PlatformBackend (kqueue on macOS, epoll on Linux) and the two libc constants whose values differ between them, and no other file imports or constructs a backend |
verified | test_platform.mojo:test_platform_backend_is_a_constructible_multiplexer (every PR) — the alias constructs through the trait-declared initializer and owns a multiplexer fd on the running OS. The other half, that nothing else chooses a backend, is check_backend_seam in scripts/check_docs.py, a rule over the tree's text that check-docs runs on every pull request |
| E13 | --doctor reports the performance-core count beside the logical count (topology.performance_cpus: hw.perflevel0.logicalcpu on Apple Silicon, equal to cpus on a machine with one performance level) and build.apple_target, the generation the binary was compiled for |
verified | Smoke test Apple Silicon topology, QoS and the native build flavor (every PR) — the count is checked against sysctl, a second source that mirrors the stdlib's fallback, and the wheel flavor must answer m1 on whatever Mac runs it, because build-serve pins its target to the oldest Apple Silicon |
| E14 | --qos / M0_QOS: on macOS the event loop runs at user-interactive QoS and pool and executor threads at user-initiated, the one placement lever Darwin offers over performance and efficiency cores; accepted and ignored elsewhere |
verified | Smoke test Apple Silicon topology, QoS and the native build flavor (every PR) — the doctor reports the knob from both spellings, and a server under it answers a loop-answered route and a pool-thread view. The effect is measured rather than smoked: docs/notes/desktop-mac-server.md |
| E15 | --spawn-workers / M0_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 fd, so an application may use what a forked child cannot (Core ML, Objective-C, CoreFoundation, libdispatch); the same supervisor, respawn and signals |
verified | Smoke test spawned workers (every PR) — two exec'd workers serve one listener, a SIGKILLed one is re-exec'd, SIGTERM to the supervisor reaps them, and the doctor reports worker_mode; on macOS the negative arm: urllib.request.getproxies() answers under spawn and kills a forked worker 3 of 3. The exec path itself: test_respawn.mojo:test_spawned_workers_run_the_exec_image_with_their_index |
| E35 | --spawn-workers re-execs a binary installed under a path that is not ASCII: the path it execs is the bytes the OS returned |
verified | Smoke test spawned workers under a non-ASCII install path (every PR) — m0serve's flat release bundle, assembled under a directory named josé, serves with --workers 2 --spawn-workers, both workers exec'd from there. executable_path rebuilt the path a byte at a time with chr(), which encodes a byte above 0x7F as two, so under /Users/josé/ every spawned worker's exec failed with ENOENT and m0serve refused to serve (78); with that loop put back, the smoke's server exits before its first answer, its log naming josé. The bytes-to-path step alone, on UTF-8 and on bytes that are not UTF-8 at all: test_respawn.mojo:test_a_path_above_ascii_keeps_its_bytes_for_the_exec |
| E17 | The zero-config pool runs one handler thread until a job has waited: only one idle thread per lane spins, a push wakes nobody while a thread of the lane is busy or spinning, a ring that holds a job and whose pop count has not moved for POOL_WAKE_AGE_NS has a parked sibling woken by the loop (a ring being drained is left alone, however deep — on a GIL build; without a GIL the pool is parallel, set_parallel: a push wakes a parked thread whenever there is one and the wait counts from the push), and every thread is woken by name on a channel of its own, most recently parked first (M0_POOL_ELASTIC=0 restores the eager wakes) |
verified | test_offload.mojo:test_a_job_behind_a_slow_sibling_is_taken_once_the_loop_wakes_a_parked_one (every PR) — the rules beside it in the same file: test_submit_wakes_nobody_while_a_sibling_is_busy, test_submit_wakes_nobody_while_a_sibling_spins, test_a_burst_into_a_parked_lane_wakes_one_thread, test_a_ring_being_drained_is_never_stalled, test_on_a_free_threaded_interpreter_the_pool_wakes_eagerly_and_counts_from_the_push, test_sequential_jobs_with_idle_gaps_stay_on_one_thread, test_a_wake_lands_on_the_parked_threads_own_channel, and the knob-off arm test_elastic_off_restores_the_chained_wake; the isolation the rules must keep is C5's smoke. The measurement, both platforms: docs/notes/elastic-pool.md |
| E16 | Workers sharing one listener share its connections: the worker that wins an accept passes the connection to the least-loaded sibling over a pre-fork SCM_RIGHTS channel, so a burst of keep-alive connections spreads across --workers N to within 2:1 on both platforms, forked or spawned, at no cost to a single worker (M0_ACCEPT_SHARE=0 restores the bare race) |
verified | Smoke test accept sharing (every PR) — two rounds of a 32-connection burst per worker mode must land within 2:1, the doctor must report accept_sharing for two workers and not for one, and on macOS the knob-off arm must skew; the descriptor passing and the load policy: test_accept_share.mojo:test_a_connection_passes_between_two_workers_with_its_peer; the in-flight count raised before the descriptor is sent and taken back if the send fails, so a receiver that admits and retires the connection before the sender's next instruction leaves nothing behind (review record R5; raised after, the retire was clamped at 0 and the late increment left the sibling one connection heavier for good): test_accept_share.mojo:test_a_receiver_that_retires_inside_the_handoff_leaves_nothing_pending. A hand-off that is not send_fd's shape leaves no descriptor open: test_a_control_message_cut_short_is_refused_and_leaks_nothing and test_a_payload_cut_short_still_hands_over_its_descriptor, where a truncation check that tested no control truncation on either platform (Linux's MSG_TRUNC bit; macOS's flags read past its 48-byte struct msghdr) had returned or leaked what it should have closed. A hand-off that arrives without its descriptor, as every one does to a worker at its open-file limit (Linux delivers the payload with MSG_CTRUNC, macOS fails the receive with EMSGSIZE and leaves the payload queued, both measured), is skipped: the connection queued behind it is admitted, and its count in pending retired, where the drain read the refusal as an empty channel and stopped: test_accept_share.mojo:test_a_refused_handoff_does_not_stop_the_drain. No hand-off is lost to a worker that is leaving (review record B25): the sender reads the target's state again after raising its pending and keeps a connection whose target has left, the leaver's drain runs on while pending, less what it has received, is above 0, and a worker that has left passes what its channel delivers on to a sibling that has not, admitting it only when none is left. Measured before the fix in a Linux container and on macOS, with the gap between pick and the send widened, a hand-off reached an idle leaver after it had exited and its client got nothing until the whole server stopped, and one that reached a leaver's drain before its first byte was closed as idle, its client reading EOF: test_handoff_leaving.mojo:test_a_sender_keeps_a_connection_for_a_worker_that_left, test_handoff_leaving.mojo:test_a_leaver_waits_for_a_handoff_counted_before_it_left and test_handoff_leaving.mojo:test_a_leaver_passes_a_handoff_on_before_its_first_byte. The wait trusts pending never to stay high, so the worker that takes the index of one that died takes back what that one had received and not yet retired, published in a fourth word of its line (taken): those connections died with it, and a count left behind would hold every later drain at the index to its budget, which under --reload runs into the reload's own 5 s deadline and SIGKILL: test_handoff_leaving.mojo:test_a_respawn_takes_back_what_its_predecessor_had_taken. Each rule sabotaged and caught by its own test. On macOS a hand-off in flight, whose sender has always closed its copy, was flushed by the kernel's collector of descriptors in flight (XNU unp_gc, which follows only descriptors in flight, never a channel that is merely open) whenever any AF_UNIX socket on the machine was closed: the request discarded, the receiver reading EOF and closing it unanswered. With another process closing AF_UNIX sockets, 179 of 1600 requests in bursts of 32 against --workers 2 were lost, none after, and it failed a test in test_accept_share.mojo and one in test_handoff_leaving.mojo on the macOS CI runner. Each channel's read end is kept in flight in a pair of its own for the life of the process, so the collector follows the channel: test_accept_share.mojo:test_a_handoff_in_flight_is_beyond_the_kernels_collector, caught in 20 of 20 runs with the anchor removed. And a receive waits out the collector's scan of the channel, which holds its buffer's lock, instead of failing EAGAIN with a datagram queued: test_accept_share.mojo:test_a_receive_that_meets_a_collector_scan_still_takes_the_datagram, caught in 20 of 20 runs with MSG_DONTWAIT restored. Both rules are macOS's; Linux's collector never takes a socket open somewhere. A worker whose loop has not started is handed nothing, as one that has left is not (review AR): its words read 0 until its start(), which in m0serve follows the application's import, and 0 read as parked with no load, so a connection handed to it waited out the startup and died with it if a stop reached it before it armed. 0 is now STATE_NOT_STARTED, which a fresh page holds before the fork, parked is a word of its own, and a replacement marks its index not started as it binds: test_accept_share.mojo:test_a_sibling_that_has_not_started_is_never_picked, sabotaged both ways (the pick's check, the replacement's mark). The smoke holds worker 1 for 3 s before its loop starts (M0_TEST_ARM_GAP_MS) and requires a burst of 32 sent inside the hold to be answered by worker 0 within 1 s, with nothing passed; before, 16 of the 32 went to the held worker and each waited out the hold. A worker the supervisor reaps and does not replace is handed nothing (review RP): a worker that died wrote its words no more, and one killed while parked read as parked with no load, so a sibling handed it about half of every burst, each accepted and never answered, once the supervisor was stopping, out of respawns, or the worker had exited 0 or 78. The supervisor marks every index it reaps left, on every reap path, before it decides whether to replace it (mark_reaped, WorkerSupervisor.share_accepts): test_respawn.mojo:test_a_crashed_worker_the_supervisor_gave_up_on_is_never_picked, test_respawn.mojo:test_a_worker_that_exits_cleanly_is_marked_as_it_is_reaped, test_respawn.mojo:test_the_polling_supervisor_marks_a_worker_it_will_not_replace and test_respawn.mojo:test_spawned_workers_are_marked_as_they_are_reaped, each caught with the mark removed; left rather than not started, so a sender that picked the worker before the reap and reads its word after keeps the connection: test_accept_share.mojo:test_a_worker_reaped_after_the_pick_is_not_sent_to, caught with the mark written as not started; and the mark does not outlive the worker, a replacement being picked again once it starts: test_respawn.mojo:test_a_replacement_is_picked_again_after_the_mark. The smoke kills worker 1 with SIGKILL, parked, until the supervisor stops respawning it, forked and spawned, and requires a burst of 32 answered every one by worker 0; before, 16 of the 32 went unanswered, and so they did with m0serve's call to the supervisor removed. What was handed to a worker before it died is closed when the supervisor gives up on it (review RB): the supervisor's copy of the channel is not the last, so each such connection was accepted and left open, unanswered, until the server stopped. The supervisor takes each off the channel and closes it, waiting up to 250 ms for a hand-off counted before its mark, and retires them from pending: test_respawn.mojo:test_a_connection_queued_to_a_worker_given_up_on_is_closed and test_respawn.mojo:test_a_handoff_that_raced_the_give_up_is_closed_too, each rule caught with it reverted by sabotage-host (pre-release). The smoke stops worker 1 with SIGSTOP, parked, while 16 of a burst of 32 are queued to it, kills it with no respawns allowed (M0_TEST_MAX_RESPAWNS=0), and requires each of the 16 to read a close within 1 s, forked and spawned: 16 of 16 within 3 ms, where without the drain all 16 were still open. The measurement, both platforms before and after: docs/notes/accept-sharing.md |
| E18 | A job pushed to a pool lane whose threads are parked or inside slow views is taken without a lost wake, on Linux, where the kernel wakes one waiter and the only lost pool wake ever caught reproduced | verified | stress-pool (pre-release) — in the m0lin container, once per shipped wake mode (the GIL rules, M0_POOL_PARALLEL=1, M0_POOL_ELASTIC=0): scripts/probes/phase5_probe.py runs smoke-django-realtime phase 5 twenty times with a fresh server each round — two 1.5 s views in flight, a hold taken on a pool thread, its subscriber must be registered half a second later; 2 of 10 rounds failed on the ring's first build (docs/notes/pool-ring-handoff.md) — and scripts/probes/hold_race_probe.py opens forty holds while three clients keep the loop busy. The deterministic half is E17's unit tests; the isolation is C5's smoke |
| E37 | A job that waits on a pool lane beside a busy thread is looked at when its lane's threshold runs out, not at the loop's next pass: while a job is pending, an idle loop waits exactly until the earliest head's deadline that no look has judged -- the head's wait start, as the stall check counts it, plus POOL_FREE_WAKE_AGE_NS on a GIL-free lane or POOL_WAKE_AGE_NS on a GIL lane (OffloadPool.next_look) -- through a wait in nanoseconds (wait_ns: the kevent timespec on macOS, epoll_pwait2 on Linux, falling back to epoll_wait's millisecond where the kernel refuses it), and a deadline is looked at once, so a lane whose threads are all busy keeps the POOL_WAKE_WAIT_MS cadence rather than spinning |
verified | test_offload.mojo:test_the_loop_looks_when_a_heads_threshold_runs_out (every PR) — a GIL lane and a GIL-free lane with a job each beside a busy thread, neither push waking anyone: the first look offered is the GIL-free head's push plus its 10 µs, a look before it wakes nobody, the look at it wakes the parked sibling, and once judged it gives way to the GIL lane's deadline and then to nothing; a GIL lane being drained is offered a look one threshold after now; no look is offered under M0_POOL_ELASTIC=0, and a head the lane's spinner took wakes nobody. The wiring beside it: test_an_idle_loop_waits_for_a_pending_heads_deadline (_wait_for_events asks the backend for wait_ns no later than the deadline, then for the millisecond once the deadline is judged), test_the_platform_backend_waits_in_nanoseconds (the best of twenty 100 µs waits on kqueue or epoll is under 900 µs and not under 90), and m0-wsgi's test_the_detaching_backend_forwards_a_wait_in_nanoseconds (m0serve's offloaded loop waits through that wrapper). Ten sabotages -- no look offered, a judged deadline offered again, the look not recorded, the GIL-free threshold ignored, a moved GIL lane counted from its old start, the wait capped at the millisecond, the look rounded up to it, kqueue's wait rounded up or down, the wrapper dropping the nanoseconds -- each fail one of them. Measured on an Apple M4 against the previous binary, interleaved in one session: probe-pool's pooled cell (four threads, 400 ms views, 300 sequential requests) 1.45–1.55 ms p50 to 0.10–0.14 at one and two slow views; m0serve's WSGI pool behind one sleeping view 1.47–1.53 ms to 0.43–0.56, its 200 µs GIL threshold most of what is left; bench-mojo-mount's probe and filtered searches at sel=5, 25 and 100 within a percent on the same cores; the mixed workload's --workers 4 +bt=4 p99 behind one and two 200 ms views 1.33–1.50 ms to 0.48–0.56 on 3.13 with the rate up 3–7 %, and unchanged on 3.14t, where the pool is parallel (its clean round within 0.3 % at every level), Granian's row as the comparator; and the server's CPU with one slow view in flight and no other traffic 0.0 % of a core for 10 s on both, 0.8 % on both with a job queued behind four busy threads |
| E19 | A view that RELEASES the GIL runs in parallel across --blocking-threads, and one that holds it does not — the pool's N threads do N things at once only when the work inside the view drops the interpreter lock |
verified | Smoke test pool parallelism (every PR) — bareapp's /work is one hashlib call over two buffer sizes either side of HASHLIB_GIL_MINSIZE, so the lock is the only variable: two at once cost 1.02x one for the releasing mode and 2.01x for the holding one, and a server given a single handler thread sends both to 2.0x. C5 is the isolation half of the same pool; this is the concurrency half, which C5 passes without. What it decides in practice: Core ML's predict holds the lock and an embedding app gained nothing from more handler threads, MAX releases it and the same app scaled — docs/notes/gil-and-the-handler-pool.md |
| E20 | With no handler pool — the loop calling a WSGI application itself, as --blocking-threads 0 (with or without --realtime) and every --workers N without a pool do — the loop releases the GIL around each wait for I/O, so a thread the application starts runs while the server is idle, as it does under a pool, gunicorn or uvicorn |
verified | Smoke test application threads without a pool (every PR) — bareapp.ticker's thread sleeps 10 ms a tick; over 3 s idle on one keep-alive connection it must tick 20 times in each of the three shapes, whose banners must name no pool — a bar set from GitHub's shared macOS runner, which measured 19 to 41 ticks over 1.5 s (nominal 150) across eleven shape-runs, not from nominal. The loop used to wait holding the GIL it has held since Py_Initialize, and the thread ran only when a request ran Python: 1 tick where 150 were due, in all three (#310). E11 is the pooled shape, which detaches by a different route |
| E21 | The Mojo host: m0_host.serve[H, P] owns a Mojo application's startup — the listener, the pre-fork shared pages and bus, the fork, accept sharing bound per worker, signals armed after the fork, a handler built in each worker by AppHandler.make, and one Producer on worker 0 alone — so the bus is drained at one worker too, and every worker's streams receive every step of the one producer, in order and once; a configuration it does not serve (M0_SPAWN_WORKERS, M0_WORKERS=0; M0_BLOCKING_THREADS too until E26 served it, and M0_THREADS until E27) exits 78, naming the variable, before anything is bound |
verified | Smoke test the Mojo host (every PR) — apps/host_check is a handler and a producer and nothing else: a stream at one worker must beat, four streams that must span two workers (a run that cannot spread them fails as vacuous) must each carry every beat with no id repeated or skipped and every beat from one process (the pid in the payload: the loop's redelivery filter hides a second producer's repeated ids, which the first sabotage run missed), and each refusal must exit 78 without the listening banner; the thread half is test_host.mojo:test_producer_publishes_to_every_worker_in_order. sabotage-host (pre-release) breaks twenty-five host rules one at a time — worker 0's channel skipped, one channel handed to the producer, the bus drained only above one worker, a producer in every worker, the share never bound, signals armed before the fork, a forked worker returning from main, the refusal skipped or placed after the bind, the thread rules under E23 and the pre-fork rules under E24 — and requires the gate to fail for each. The design and what the host does not do: the-mojo-host |
| E26 | The Mojo host serves M0_BLOCKING_THREADS=N as a handler pool behind each worker's loop: N MojoPool threads on one lane the host marks GIL-free, each building the application's handler again with AppHandler.make on its own thread (PoolLane[H], HostContext.thread naming the instance), so a trivial route answered on the loop stays answered while every pool thread is inside a slow view, and the same load on the bare loop stalls it; a stream opened on the loop (before_request, or on_loop=True on a table) works under the pool at one worker and two, while one begun in func on a pool thread is refused 409 and named in the log, as on a Mojo mount; a make that raises on a pool thread is the same 78 the loop's gets, before the server takes a connection, never a pool one thread short; and 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 step past its bound leaves inside one 5 s bound, not two |
verified | Smoke test the Mojo host (every PR) — apps/host_check under M0_BLOCKING_THREADS=2: two connections looping a 200 ms spin hold BOTH threads (with a thread to spare an idle thread answers a job within a millisecond and the phase could not tell it from the loop), and the worst of 24 /health samples (answered in before_request) under 100 ms (0 ms measured on an M4), where M0_BLOCKING_THREADS=0 under the same load must reach 100 ms (389 ms measured) or the arm proves nothing; two streams opened in before_request under that pool carry every beat, and four spanning two pooled workers do; /events-from-func answered 409 with the refusal in the log; M0_HOSTCHECK_POOL_MAKE_RAISES=1 exiting 78 at one worker and two, naming the knob, with no worker left; and a 4 s request in flight at SIGTERM beside a 60 s step answered whole with the process gone in 6 s (5.05 s measured; 8.54 s with the stop word unstamped, the two bounds in sequence). The unit halves are test_host.mojo:test_a_pool_lane_builds_the_handler_for_its_thread, test_a_pool_lane_reports_a_make_that_raised and test_a_stop_stamped_by_the_loop_shortens_the_join, and test_mojo_pool.mojo:test_wait_ready_counts_a_thread_whose_make_raised. sabotage-host (pre-release) serves the variable on the loop, withholds the stop word from the loop, counts the producer's join from the loop's return, serves a raising pool make one thread short, builds a pool thread's handler as the loop's own, and moves the gate app's /health onto the pool. The design: the-ramp-test |
| E22 | A Mojo host application under M0_WORKERS=2 shares its accepts as m0serve does (E16): a burst of keep-alive connections lands within 2:1, with connections actually passed between workers, and M0_ACCEPT_SHARE=0 restores the bare race |
verified | Smoke test the Mojo host (every PR) — scripts/accept_spread.py --app-bin runs E16's measurement on apps/host_check: two rounds of a 32-connection burst within 2:1 and a non-zero hand-off count, and on macOS the knob-off arm must skew (measured 16/16 against 27/5 on an M4). The host's supervisor marks a worker it reaped and did not replace as m0serve's does (E16, review RP): accept_spread.py --app-bin --reaped kills worker 1 until it is not respawned and requires a burst of 32 answered by worker 0; with the host's call to the supervisor removed, 16 of the 32 went unanswered, and sabotage-host removes it. The same run then requires the 16 connections queued to a stopped worker 1 to read a close within 1 s of its death, E16's measurement (review RB). No app under apps/ shared its accepts before the host |
| E23 | The host's producer ends with the server: told to stop as the drain begins and joined within what the drain left of the 5 s bound (E26), its sleeps sliced so a long period never delays the stop; a step still running at the bound is abandoned by name and the process exits 0, in one process and in a forked worker alike; a drain signalled to the supervisor alone ends every held stream and both workers report a clean exit; the cadence never catches up after an overrun, and a step that raises ends the producer with its status | verified | Smoke test the Mojo host (every PR) — a 60 s step must leave 5 s after SIGTERM with the producer named, a 60 s period must leave at once, and the supervisor-only drain must end four held streams across two workers with no worker killed by the signal; the thread rules without a server are test_host.mojo:test_an_overrunning_step_is_abandoned_within_the_bound and its neighbours (no catch-up, proven by one overrun followed by free steps, since steps that all overrun run back to back either way; a prompt stop; a raising step's status; a counted refusal). sabotage-host (pre-release) removes the stop, the bound, the _exit, the sliced sleep and the status-last write, among others |
| E24 | One pre-fork preparation serves both hosts: m0_http.prefork makes the shared page (file-backed where the host allows it, the magic word stored, M0_SHARED_ID_FD and M0_SHARED_ID_ADDR exported), the bus and the accept-share channels for m0serve and for the Mojo host alike, each exported by descriptor number; a spawned worker adopts all three by those numbers, the page mapped again in its own image and its address re-exported before anything reads it, and a spawned worker handed no page descriptor is refused rather than served from an address that is not its own |
verified | test_prefork.mojo:test_a_spawned_worker_adopts_the_page (every PR) — a page made with required=True and stored through, then adopted under the spawn marker, must sit at a new address, be the address shared_id_addr() reports, carry the store and the magic word, and a store through the adopted mapping must reach the original; its neighbours cover the bus and the share adopted by descriptor, the one-worker and M0_ACCEPT_SHARE=0 cases, and the missing-descriptor refusal. The exec itself is E15, which runs on the same functions. sabotage-host (pre-release) breaks six of its rules against that file — the adopt paths replaced by fresh pages and channels, the address not re-exported, the magic word and the send ends not stored, the knob ignored |
| E25 | The host's contract holds through its own supervisor: a producer's event ids come from the pre-fork shared word (Publisher.next_id), the one the handlers' own publishes number from, so a respawned worker 0's producer continues above what every held stream has seen rather than restarting at 1 below it and leaving those streams silent for the pre-crash uptime; and a make that raises — the handler's in any worker, the producer's on worker 0, built on the spawning thread before the server listens — is a refusal, exit 78 with the error named under host:, never a crash loop, and one worker's refusal ends its siblings rather than leaving them serving a host missing the worker that refused |
verified | Smoke test the Mojo host (every PR) — four streams spanning two workers are held through a SIGKILL of worker 0, and those on worker 1 must beat again within 2 s of the kill, from the respawned producer, with an id above their last (12 ms measured; before the fix no beat arrived inside the bound after 41 beats over 4 s, the silence being the pre-kill uptime) — a fresh stream beating is no evidence, since that passes on the broken host; and M0_HOSTCHECK_MAKE_RAISES=1 and M0_HOSTCHECK_PRODUCER_RAISES=1, at one worker and two, must each exit 78 naming the knob, with no respawn or crash line and no worker left. The thread halves are test_host.mojo:test_publishers_number_from_the_one_shared_word (two publishers over one word, a handler's take between them) and test_a_raising_make_starts_no_thread (STATUS_NEVER_RAN, distinct from a raising step's STATUS_RAISED); the supervisor's half is test_respawn.mojo:test_a_refusal_by_one_worker_ends_its_siblings. sabotage-host (pre-release) creates the id word after the fork, stops next_id advancing it, lets the handler's raise propagate, exits 1 for the producer's, swallows it inside start, and leaves the siblings serving |
| E27 | The Mojo host serves M0_THREADS=N as N event loops on N threads of ONE process: 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 with AppHandler.make (HostContext.worker naming the loop, threaded the kind), and fan-out across loops rides the bus, one channel per loop, from one producer thread — so every loop's streams carry every beat; accept sharing runs across the threads unchanged, a passed descriptor arriving in the same process; and M0_BLOCKING_THREADS composes as T loops of N |
verified | Smoke test the Mojo host's loops on threads (every PR) — apps/host_check under M0_THREADS=2: the banner names two loops and no worker was forked; four streams that must SPAN both loops (x-worker, or the phase fails as vacuous) each carry every beat, contiguous, from one pid; and under M0_BLOCKING_THREADS=2 as well, each loop starts its own pool, the four streams still beat, and the worst of 24 /health samples behind two spinning connections stays under 100 ms. There is no interpreter in a Mojo host, so nothing here skips on CI's GIL-enabled CPython, unlike B-section's threaded rows. sabotage-host (pre-release) drains only loop 0's bus channel and builds every loop's handler as loop 0. Measured on macOS without accept sharing: 30 of 32 connections on one loop of four; with it, 8 each. The design and the measurement against prefork: loops-on-threads |
| E28 | A threaded Mojo host drains as one process: the signal handler's one pipe wakes the spawning thread, which pokes a shutdown pipe per loop, every loop finishes the request it holds and leaves, its pool joined within what the drain left of the bound, and main returns 0 — nothing was forked, so there is no exit_worker; a loop still running past its bounds is named and the exit is 1; and a loop that raises takes the process, there being no supervisor to replace it |
verified | Smoke test the Mojo host's loops on threads (every PR) — a 1.5 s /slow request written on one connection per loop (the probe first proves the connections sit on different loops), SIGTERM while both spin: both answered 200 whole, exit 0 inside 8 s (1 s measured), no loop or producer reported left behind. sabotage-host (pre-release) hands only loop 0 its shutdown pipe: loop 1 never sees the stop, the coordinator's bounded join gives up on it and the exit is 1 |
| E29 | Under M0_THREADS every loop's handler is its own instance, and an application whose state lives in one handler is refused rather than served as N copies: AppHandler.max_threads() defaults to max_workers(), so the application that refused workers refuses loops without being asked (ViewsApp forwards it), and one whose state really is shared across loops overrides it; M0_WORKERS>1 with M0_THREADS>1 is refused, as is M0_THREADS=0; each with 78 before the bind; and no loop takes a connection until EVERY loop has its handler, so a make that raises on any loop is a 78 before anything was served |
verified | Smoke test the Mojo host's loops on threads (every PR) — five /instance requests on keep-alive connections spanning both loops: one pid, and each loop's handler has counted exactly its own requests, 1..n; M0_THREADS=2 with M0_HOSTCHECK_MAX_WORKERS=1, with M0_WORKERS=2, and M0_THREADS=0 each exit 78 naming the reason with no listening line; M0_HOSTCHECK_LOOP_MAKE_RAISES=1 raises on loop 1 alone, 300 ms late, and exits 78 with no Event loop started line. Smoke test the fragment notes app (every PR) refuses M0_THREADS=2 for the notes app, which says only max_workers() -> 1. The unit halves are test_host.mojo:test_the_host_refuses_more_loops_than_the_app_serves and test_workers_and_threads_are_one_or_the_other. sabotage-host (pre-release) removes the barrier, the loop limit, the default, the conflict and ViewsApp's forwarding |
| E30 | A Mojo host application has a command line, and its precedence is m0serve's: flag over M0_ variable over default. serve lays the flags over the AppConfig it is handed — --host, --port, --workers, --threads, --blocking-threads, --access-log, --sse-heartbeat-ms, --app-tick-ms, --max-keepalive-requests, --qos, --spawn-workers (read, and refused as its variable is), --doctor and --help — so serve(AppConfig()) is still an application's whole main; a flag marks its count as chosen exactly as its variable does; a flag that names one of the four fields a ServerConfig shares outranks tuning the application set in code, and one that was not given leaves it alone; the overlay is idempotent, which is what lets an application that prints its own address take host_config() and be served the same. Strict where the environment is lenient: an unknown flag, a missing or unreadable value, a value on a boolean or any positional is the usage and exit 2 (a port outside 1–65535 is read, and refused with 78 by E31's first check, as its variable is), before anything is bound — an application's own settings stay in its own variables |
verified | Smoke test the Mojo host's flags and doctor (every PR) — apps/host_check answers /health on the port a flag names while M0_PORT names another, which nothing then answers on; an access-log flag logs the request; two workers and two pooled loops asked for by flag are what the banner and the log show; and the first two hold in BOTH application shapes, serve(host_config()) and serve(AppConfig()), because the first applies the command line before serve sees it and hid whether serve does (two sabotages MISSED until the second shape was run). Five unreadable command lines each exit 2 with the usage and the reason, served and doctored alike, and --help exits 0 having started nothing. The unit halves are test_host_flags.mojo:test_a_flag_outranks_its_variable_which_outranks_the_default and test_what_cannot_be_read_is_a_usage_error, beside the idempotence, *_set and given-flag tests. sabotage-host (pre-release) serves the environment under a command line it read, withholds a given flag from the ServerConfig, prints the gate app's banner from the environment alone, ignores an unknown flag, leaves a count unmarked, leaves the base URL on the old port, and overwrites the application's tuning with a flag nobody gave. The design: flags-and-a-doctor-for-the-host |
| E31 | A Mojo host application's doctor: the binary prints the configuration it WOULD serve as one JSON object — the last line of its stdout, in m0serve's report shape (m0_http.doctor.Report: ok, exit, grouped facts, checks each carrying its fix) — and exits with the code serving would exit with for the same arguments, having bound nothing. The report gives the resolved configuration, where each value came from (flag, env or default), the topology it adds up to and what the application declares (max_workers, max_threads, page_slots). The code agrees by construction and is gated anyway: host_checks is the ONE description of what the host refuses, in the order it refuses, the server exiting 78 on its first failure and the doctor listing them all and exiting on the same one — so a count that cannot be served is one refusal whether a flag or a variable brought it, never a usage error for one and a 78 for the other. An application's own checks run in its main before serve and so fire identically under the doctor. NOT covered, because finding out means doing it: the bind, and the application's make |
verified | Smoke test the Mojo host's flags and doctor (every PR) — twenty-three configurations of apps/host_check are each run both ways and the two exit codes must agree with each other and the table: five served (0, really served and drained), eleven refused (78: both modes by flag and by one flag and one variable, zero loops, zero workers, a port of 0 by variable and by flag and one of 70000 by flag, a host with an IPv6 zone, more workers and more loops than the application declares, exec'd workers), one tripping two refusals at once where both must name the first, five usage errors (2, no report), and the application's own refusal (78 both ways, the doctor never reached); every report's exit and ok match its process, a refusal's first failed check is the named one and its detail and fix are the line the server printed, no doctor run logs a listener, and doctoring a port a live server holds exits 0. The unit halves are test_host_flags.mojo:test_the_doctor_leaves_with_the_servers_code, test_the_checks_are_whole_and_in_the_order_serve_refuses and test_a_count_that_cannot_be_served_is_a_refusal_not_a_usage_error. The port is the list's first check: a port outside 1-65535 is refused with 78 whichever of --port and M0_PORT named it, where the variable's 0 served on a port the kernel chose and the flag's was a usage error (review record LF56, test_a_port_out_of_range_is_refused_whichever_way_it_came). The second is the address: one the listener would refuse, such as a % in the host, is refused with 78 there, where the server failed at the bind with 1 and the doctor said 0 (review record LF48, test_a_listen_address_that_does_not_parse_is_refused). sabotage-host (pre-release) has the doctor ignore what the application declares, always exit 0, and start the server anyway; has the server refuse by its last failed check; and makes zero loops a usage error for the flag alone |
| E32 | The Mojo host refuses forked workers when MAX's parallel runtime is linked: M0_WORKERS above 1 in a binary that carries libAsyncRTMojoBindings — what max.algorithm.parallelize needs, and whose worker threads a fork() does not copy, so a parallelize in a forked worker never returns — exits 78 before the bind, naming --threads (M0_THREADS) as the fix, and --doctor reports the same check (workers-vs-parallel-runtime, last in host_checks); the fact is read off the loaded images (RTLD_NOLOAD on Linux, dyld's image list on macOS), never by calling MAX, so a binary without it answers the check as passed (a mojo run has no binary of its own: it runs in the compiler's process, which maps the runtime once max-core is installed, so there every program reads as linked — ROADMAP Known issues); and the same binary serves parallelize from one loop, from N loops on N threads and from a handler-pool thread |
verified | Smoke test the Mojo host with MAX's parallel runtime (every PR) — apps/host_parallel, built against max-core synced by that step alone (the max dependency group, synced by that step and used by E33's alone), refused by the doctor and by the server at two workers, served at two loops, at a pool of two and alone, each /par answered and eight at once, every wait bounded so a refusal that stopped refusing fails on a served prefork rather than hanging CI (sabotage-host --only parallel); measured before the refusal: the request under two forked workers never answered, the worker's loop stayed wedged and SIGTERM had not ended the process after 12 s. The verdict with the fact supplied by hand is test_host_flags.mojo:test_prefork_is_refused_when_the_parallel_runtime_is_linked; and, since 2026-09-25, the release recipe run on a copy of the same binary — relocate.py, then bundle_artifact.py, the two scripts the m0 wheel ships unedited — with libAsyncRTMojoBindings required beside it and the bundle answering --doctor and serving /par from its own directory with the build venv unreachable (N41's other half, measured where MAX is installed) |
| E33 | m0serve refuses a forked worker when MAX's parallel runtime is linked: a Mojo mount that imports max.algorithm.parallelize puts libAsyncRTMojoBindings in the image, and --workers N above 1 or --reload (which supervises even one worker, forked) then exits 2 before the bind, naming --spawn-workers, whose worker execs the binary and starts the runtime fresh; --doctor reports the same check (workers-vs-parallel-runtime) and topology.parallel_runtime; the fact is m0_http.parallel_runtime_linked, the one function the Mojo host reads (E32), so the shipped bin/m0serve passes the check at two workers where its load commands lack the runtime and is refused where they name it — on macOS a build made beside an installed max-core links libAsyncRTMojoBindings whether or not the source imports MAX (ROADMAP Known issues) |
verified | Smoke test m0serve with MAX's parallel runtime (every PR) — apps/serve_parallel/mount built in place of the demo mount (N14) over host_parallel.compute, E32's job, under max-core: refused by the doctor and by the server at two workers and by the doctor under --reload, passed under --workers 2 --spawn-workers and --reload --spawn-workers, the shipped binary's verdict held to its own load commands (otool -L, readelf -d: passed at two workers on Linux, refused on macOS beside the installed MAX, the doctor's parallel_runtime agreeing either way; measured on CI 2026-09-25: the demo-mount bin/m0serve bundled three runtime files in the MAX-free apple-silicon job and four, the runtime among them, after uv sync --group max), then served — two exec'd workers each answer /par/par (by x-pid, sixteen at once), /par/ser agrees on the digest, SIGTERM drains the supervisor to 0, and one process answers from a pool thread — every wait bounded so a refusal that stopped refusing fails on the served prefork rather than hanging CI. Measured with the server's refusal removed (2026-09-25, Linux, four cores): the forked worker answered /par/ser in 23 ms and /par/par never, twice, one pool thread per worker gone until the drain's 5 s join abandoned it; the probe failed at its served-prefork phase. The verdict with the fact supplied by hand is test_cli.mojo:test_parallel_runtime_forked_truth_table, in a binary that links nothing |
F. Observability
| id | capability | status | evidence |
|---|---|---|---|
| F1 | Access log records cannot be forged by a value (newline, quote, backslash escaped) | verified | test_log.mojo:test_a_newline_cannot_forge_a_second_log_line (every PR) |
| F2 | --access-log emits one JSON record per response, and nothing without it; its bytes is the body as sent |
verified | Smoke test the serve CLI (every PR) — --access-log: an in-memory body's length, 0 for a HEAD, and a static file's size, which goes out by sendfile. bytes was the encoded buffer, the head plus an in-memory body and none of a file body, so a 64-byte body logged 366 and a static file about its head's size; the metrics' bytes sent left the file body out too (test_slot_lifecycle.mojo:test_a_file_body_is_counted_as_sent) |
| F3 | --metrics turns /__metrics from the application's 404 into a 200 |
verified | Smoke test the serve CLI (every PR) — --metrics |
| F4 | Prometheus exposition 0.0.4: 9 counter and gauge families, each with HELP, TYPE and a sample | verified | Smoke test the serve CLI (every PR) — --metrics, which checks eight of them on the wire; test_metrics.mojo:test_every_family_has_help_type_and_its_sample holds all nine, http_bus_frames_refused_total (I40) among them, and that each sample is the field it names |
| F5 | Latency histograms on /__metrics |
verified | Smoke test the serve CLI (every PR) — six log-spaced le bounds (100µs–1s, +Inf), integer-only and O(1) on the loop thread; scripts/histogram_check.py (selftested in the same phase) asserts the documented bounds, non-decreasing cumulative counts, le="+Inf" equal to _count, and a _count covering the phase's own requests; boundary math pinned by test_metrics.mojo |
| F12 | Coverage declared by the gate rather than cited by this page | verified | Check machine-sourced doc facts (every PR) — every pull-request-gated row declares its coverage in the gate itself (a covers: docstring line in the cited test, or a recorder coverage call in what the cited step runs), and the checker requires the declaration to agree with the citation, which is what makes the audit's mis-citation class structurally impossible. Weekly and pre-release rows keep declared-static citations, their runs being absent from PR CI. Four sabotages in poe sabotage-spec revert the rules |
| F6 | --health-path answers 200 |
verified | Smoke test the Django realtime example (every PR) — --health-path |
| F7 | Health registry with readiness aggregation | verified | test_health.mojo:test_health_register_unhealthy (every PR) |
| F19 | An M0_* value m0serve cannot read is ignored out loud: the environment stays lenient, and one line at startup names the variable, its value and what was used instead, printed by the process that read it and not again by a worker that re-executes |
verified | Smoke test the serve CLI (every PR) — M0_MAX_BODY=64mb and M0_PORT=80eighty under --doctor each named with the value used, the doctor's cap at its default; a readable value named by nothing; and M0_QOS=yes reported once under two spawned workers. It was silent, so M0_MAX_BODY=64mb would have kept 4 MB unnoticed. test_cli.mojo:test_from_env_names_a_number_too_large_for_an_int holds a number too large for an Int, which wrapped (M0_PORT=18446744073709551696 served on port 80) and is now one more value read as unreadable and named (review record LF49) |
| F20 | The access line is written straight into one buffer and is byte for byte the record format_json renders for the same fields |
verified | test_log.mojo:test_format_access_is_format_json_byte_for_byte (every PR) — a plain GET, a HEAD with a quote and a backslash in its path and no address, and a 413 from an IPv6 peer. The line skips LogEntry, whose lists and per-number strings were most of its cost: about 170 ns a line against about 680, measured on an M-series Mac with the wall-clock read included |
| F21 | The access record's status, dur_us and bytes are JSON numbers, and its time is the wall clock: RFC 3339 in UTC with milliseconds, read once per line with the second cached per loop |
verified | Smoke test the serve CLI (every PR) — --access-log: the three fields parse as integers, ts is gone, and time matches the format and lies within a minute of the runner's clock. Numbers were strings, so jq 'select(.status >= 500)' matched every line, and ts was a monotonic count from an arbitrary origin; the calendar arithmetic against Python's datetime at the epoch, leap days and 9999: test_log.mojo:test_rfc3339_second_is_the_civil_date_in_utc |
| F23 | http_requests_total counts requests answered, as each response's head lands, and every one with a status from 100 to 599 is in exactly one class of http_responses_total, a 101 in 1xx, so for those statuses the classes sum to the total |
verified | test_metrics.mojo:test_every_answered_request_is_in_one_status_class (every PR) — a 101 Switching Protocols, the answer to every WebSocket upgrade, was in the total and in no class, and the total's help text said "received" of a count the loop takes per response (review record LF32). The count stays per response: counting at receipt would move it into the event loop, and a count labelled by status is taken at completion, as Prometheus's Go promhttp counter takes it once the handler returns. A request whose connection closes before its response lands is in neither |
| F25 | The listening line ListenConfig.listen prints, a Mojo host application's startup line, names the address the listener is bound to: asked for port 0, it names the port the kernel chose |
verified | test_ipv6.mojo:test_the_banner_names_the_port_bound (every PR) — read from a pipe standing in for stdout, for 127.0.0.1:0 and [::1]:0. It named the port it was asked for, http://127.0.0.1:0, a port nothing can connect to; reached by a port of 0 an application passes Server.listen_and_serve or ListenConfig.listen itself, such as one not on the Mojo host (review record LF47); m0serve and the Mojo host refuse a port of 0 (LF56) |
| F8 | Configuration report that exits as the server would | verified | Smoke test --doctor against the server's own exit codes (every PR) — --doctor |
| F9 | OpenTelemetry tracing | out of scope | no tracing context crosses the Mojo/Python seam today; a wrapper in the application is the supported route |
| F10 | CI measurements recorded, rendered per run and kept as an artifact | verified | Check machine-sourced doc facts (every PR) |
| F11 | The measurement recorder itself | verified | Self-test the measurement recorder (every PR) |
| F17 | The sabotage harnesses' own judge, scripts/sabotage_lib.py: a sabotage that does not compile, or whose gate fails without the text its rule names, is a miss and never a catch; an anchor must match exactly once; a skipped rule is never summarised as guarded; SIGINT and SIGTERM put the tree back and end the harness by the signal |
verified | Self-test the sabotage library (every PR) — canned files through a stand-in mojo replaying the driver's output as measured on Mojo 1.1.0: a gate that passes is MISSED, a build error and a failed constraint are MISSED (does not compile), a double anchor is NOT APPLICABLE naming the file and the anchor, a timeout is settled by building the source alone, and a SIGINT or SIGTERM mid-run puts both files of a two-file rule back, ends the gate and the server it started, and kills the harness by the signal |
| F14 | The site's deploy image: the wheel in python:3.12-slim with the built site, served from a container as it deploys |
verified | Smoke test the documentation site's deploy image (every PR) — deploy/site/Dockerfile built from the tree's own wheel; through a published port, llms.txt and the sitemap answer at the root, a page answers with the Markdown twin it advertises, the slash redirect and the HTML 404 come from the application, m0serve is PID 1 by /proc/1/cmdline, and docker stop is the drain's exit 0 inside its grace |
| F15 | Every RFC the tree cites is a current document | verified | Check machine-sourced doc facts (every PR) — scripts/check_citations.py looks every RFC nnnn in a tracked text file up in scripts/rfc_status.json, a committed snapshot of the RFC Editor's per-document JSON; a citation with no entry fails, and a citation of an obsoleted RFC fails unless the same paragraph cites a successor. Section numbers are not checked. Selftested, and each rule reverted against the tree, by the docs gate (scripts/docs_gate.sh), which check-docs and docs.yml both run |
| F16 | The RFC snapshot is compared with the RFC Editor monthly | verified | Compare the snapshot with the RFC Editor (monthly) — citations.yml re-fetches every snapshot entry and fails on a changed obsoleted_by or status, filing an issue; a transport failure fails too, because the job exists only to ask. F15 checks that this workflow still exists, has a cron and passes the live flag |
| F13 | The documentation site: the tree's own pages rendered and served by the server, llms.txt at the root, a Markdown twin beside every page |
verified | Smoke test the documentation site (every PR) — built by scripts/docsite.py, served through --static with apps/site behind it; every sitemap URL answers as HTML with the twin it advertises, the root text files name the site's pages by absolute URL, the sitemap goes out as XML, and the slash redirect and HTML 404 come from the application |
| F18 | A stream is one response to the access log and the metrics: recorded once, when its head lands, however its frames go out | verified | Smoke test connections that stop reading (every PR) — a chunked 8 MiB /stream and a /ws/flood WebSocket, each read by a client that stalls first so their frames finish through the write-ready path, must leave exactly one --access-log record each; before the fix (review record R3) they left 5 and 2, each frame the write-ready path finished being recorded as a response of its own, counted by --metrics and timed as the stream's age, and a chunked stream recorded once more when its terminator landed. The count is test_slot_lifecycle.mojo:test_a_stream_is_recorded_once |
| F22 | A request's dur_us in the access log, and its sample in the latency histogram, run from the request's first bytes whatever header_read_timeout is; a connection's first request from its accept |
verified | test_slot_lifecycle.mojo:test_the_access_log_times_a_request_without_a_header_timeout (every PR) — a keep-alive request read with the header timeout off, its slot's stamp zeroed as the keep-alive reset leaves it, the access line read off the loop's stdout. A keep-alive request's first bytes were stamped only while the timeout was on, and the access log subtracted the stamp anyway: with it off every request after a connection's first logged the time since the machine booted (658229997495 in the test's first run), and the histogram, which asked for a stamp first, recorded nothing for them. The accept's own stamp timed the first request. Found by the 2026-10-07 fork review |
G. Security hardening
| id | capability | status | evidence |
|---|---|---|---|
| G1 | An injected status reason phrase is emptied, not transmitted, and its code kept, on every response the server writes | verified | test_response_splitting.mojo:test_an_injected_reason_phrase_is_emptied_and_the_code_kept (every PR) — judged on the bytes of both encoders (encode, encode_into) and the printed form, which every response passes through, Mojo-native and gateway alike. Until 2026-09-28 the rule lived only in m0-wsgi's split_status, whose copy was deleted on 2026-09-29 as redundant: the encoders are status_text's only readers, and split_status now passes the phrase on and keeps the code (test_response.mojo:test_an_injected_status_keeps_its_code) |
| G2 | A response header or Set-Cookie line whose name or value carries CR, LF or NUL is dropped, and a clean one beside it is not, on every response the server writes; a cookie a Mojo view builds (Cookie) is dropped too, as silently, when its name is not a token (RFC 6265 §4.1.1) or its value, Domain or Path holds a ; or a control byte, so no field of it adds an attribute or ends the line, and every other value (base64, JSON, a space, a byte above 0x7F) is written as given, while an application's own line still goes out as given (G3) |
verified | test_response_splitting.mojo:test_an_injected_header_is_dropped_and_its_neighbours_kept (every PR) — Headers.write_latin1_to and the cookie jar's writer, judged on the bytes each encoder writes, with test_an_injected_cookie_line_is_dropped_and_the_others_sent beside it and a position sweep of the scanner. The refusal lived only in m0-wsgi until 2026-09-28, so a head built in Mojo -- a view's, the Mojo host's, a --mount X=mojo pool thread's -- went out uninspected. Conformance test the WSGI bridge still sends an injecting WSGI route over a socket and requires the clean header and none of the injected ones. test_reply.mojo:test_redirect_percent_encodes_a_control_byte_in_its_target holds the constructor's side: reply.redirect percent-encodes every C0 control byte and DEL in its target, as url_for encodes one, so a Location a view built from request data is kept rather than dropped, and an ordinary target is byte-identical. test_response_cookies.mojo:test_a_built_cookie_that_would_break_its_line_is_dropped holds the built cookie's rule on both writers, dropping a ; or a control byte in each field and a name that is not a token, and keeping a base64, a JSON-ish and a spaced value: Cookie("theme", "dark; Domain=evil.test") went out as theme=dark; Domain=evil.test, a cookie for another site, and a name with a space or an = as some other cookie (review record LF55) |
| G19 | An overlong UTF-8 encoding is not a way past G2: the header writers' latin-1 transcoder decodes only the two-byte sequences of U+0080 to U+00FF, so the overlong form of CR, LF or NUL in a header value or a Set-Cookie line goes out as the bytes it was given |
verified | test_response_splitting.mojo:test_an_overlong_line_break_is_not_a_way_past_the_refusal (every PR) — on the bytes of both encoders and the printed form, with test_an_overlong_line_break_in_a_cookie_is_not_a_way_past_the_refusal for the cookie jar's writer, test_the_transcoder_maps_only_latin1_and_passes_the_rest_verbatim for the transcoder's table, and test_reply.mojo:test_redirect_to_an_overlong_line_break_keeps_the_head_whole for reply.redirect. The transcoder decoded three- and four-byte sequences too, AFTER Headers.write_latin1_to had refused CR, LF and NUL, so E0 80 8D E0 80 8A went out as a real CRLF. unquote makes those bytes of ?next=%E0%80%8D%E0%80%8A..., and reply.redirect percent-encodes control bytes but leaves bytes above 0x7F alone, as it must for UTF-8, so a Mojo view redirecting to a request's next wrote a Set-Cookie, or a body, of the request's choosing: every response built in Mojo was exposed (a view's, the Mojo host's, a pool thread's under a Mojo mount). The gateway was not: a WSGI or ASGI str crosses as CPython's UTF-8, which is never overlong, and a bytes value is re-encoded byte by byte into two-byte sequences that decode back to the bytes given. Restoring the three- and four-byte branches fails all four tests. Beside the fix, Headers.write_latin1_to and the cookie jar's latin-1 writer drop a value whose TRANSCODED bytes hold CR, LF or NUL: an ungated backstop, unreachable with the fixed transcoder, whose removal alone fails nothing; with the old transcoder it turns the split into a dropped header, and with both reverted the head carries the split. Found by the 2026-10-07 fork review |
| G3 | An application's Set-Cookie reaches the wire verbatim |
verified | test_response_cookies.mojo:test_raw_line_reaches_the_wire_verbatim (every PR) |
| G17 | A Set-Cookie line's bytes above 0x7F go out in ISO-8859-1, as every other header's do: a WSGI application's latin-1 code points, and an ASGI application's own bytes |
verified | test_response_cookies.mojo:test_a_line_above_ascii_goes_out_latin1_like_every_header (every PR) — on the bytes of both encoders. The bridge stores every header as UTF-8 and the header writer transcodes it back, but the cookie jar wrote its lines' UTF-8 as it stood: bin/m0serve sent a WSGI application's caf\xe9 as caf\xc3\xa9, and an ASGI application's own caf\xc3\xa9 as caf\xc3\x83\xc2\xa9 |
| G22 | Two request cookie jars are equal when they hold the same names with the same values, whatever their number and order | verified | test_request_cookies.mojo:test_jars_holding_the_same_cookies_are_equal (every PR) — RequestCookieJar.__eq__ compared every cookie of one jar with every cookie of the other, so a jar of two or more was unequal even to a jar parsed from the same field (review record LF30). The test holds a changed value, a changed name and a missing cookie unequal too. The server never compares jars; an application with == did |
| G28 | A request cookie reaches a Mojo application as its client sent it: each pair split at its first =, its name trimmed and case-sensitive, its value kept whole, double quotes included (RFC 6265 §4.1.1, as draft-ietf-httpbis-rfc6265bis-22 reads it), a pair with no = or no name skipped, and a repeated name's last value kept |
verified | test_request_cookies.mojo:test_a_quoted_value_keeps_its_quotes (every PR) — an inherited TODO asked for the quotes to be stripped; the bis draft says they are part of the value, so an application reads back what it set (review record LF53). test_the_pairs_edges holds the empty field, the empty pairs, the nameless pair, the whitespace beside = and the repeated name. A WSGI or ASGI application reads the raw Cookie header instead (HTTP_COOKIE) |
| G4 | Proxy request header never becomes HTTP_PROXY (httpoxy) |
verified | test_environ.mojo:test_proxy_header_is_excluded_from_the_environ (every PR) |
| G29 | A request header whose name holds _ never reaches the WSGI environ, so a client's X_Forwarded_For cannot take the HTTP_X_FORWARDED_FOR a proxy's X-Forwarded-For maps to (CVE-2015-0219); every dashed name, X-Forwarded-* among them, still does |
verified | test_environ.mojo:test_underscore_header_is_excluded_from_the_environ (every PR) — CGI's mapping turns - into _, so both spellings were stored under one key and the later in the request won: behind a proxy that forwards underscore names (HAProxy, Caddy and Traefik do; nginx drops them by default) a client set the address, scheme or identity header the application read. Dropped as gunicorn's header_map drops it by default since 22.0, and as Django's runserver does. The test walks a header map holding both spellings in each order, and the underscore one alone; Conformance test the WSGI bridge sends both to bin/m0serve, the underscore one last, and requires the dashed value and no trace of the other. The ASGI executor's scope carries every name as sent, as the ASGI spec asks; the buffered ASGI path builds its scope from the environ and drops them with it. Found by the 2026-10-10 review |
| G5 | Path traversal rejected (../) |
verified | test_static.mojo:test_dotdot_is_rejected (every PR) |
| G6 | Percent-encoded traversal rejected (%2e%2e) |
verified | test_static.mojo:test_encoded_dotdot_is_rejected (every PR) |
| G7 | Reserved \x01 channel namespace refused by publish_to_channels |
verified | test_broadcast.mojo:test_publish_rejects_reserved_channel (every PR) |
| G8 | ...and by BroadcastBus.publish |
verified | test_broadcast.mojo:test_bus_publish_method_rejects_reserved_channel (every PR) |
| G18 | ...and by take_hold at the hold boundary, which also strips M0-Channel from every response it runs on — even one carrying no usable M0-Hold (an application's leftover header, or one the bridge dropped for a control byte), since the channel is an instruction to the server and must never reach a client — and refuses a channel in the reserved \x01 namespace as it refuses a missing one, that namespace addressing a connection slot on the loop and %01 in a form field decoding to a real 0x01 the bridge does not drop |
verified | test_hold.mojo:test_reserved_channel_degrades_and_strips (every PR) — with test_channel_alone_is_stripped for the orphan M0-Channel and test_reserved_channel_degrades_a_websocket_hold for the socket path; Smoke test the Django realtime example sends M0-Channel on a response with no M0-Hold and requires it stripped on the wire |
| G9 | API key authentication, length-checked so a repeated key fails | out of scope | removed 2026-09-29 with M0_API_KEY, which nothing in the tree read, so no request was ever refused by it and no served behaviour changed. An application authenticates in its own views, with the layer's signed session and CSRF check (N13, N43) or a signed grant (I21); a key checked in front of the server is a proxy's job. DECISIONS D54 records what the packages promise |
| G10 | CORS, configurable | verified | Smoke test the notes API (every PR) |
| G11 | X-Forwarded-* / Forwarded parsing with a trusted-proxy allowlist |
out of scope | the server never consults them — REMOTE_ADDR is the socket peer and wsgi.url_scheme is configuration, so there is nothing to spoof |
| G12 | PROXY protocol v1/v2 | out of scope | same reason: the peer address is taken from the socket |
| G13 | Parser fuzzing in CI | verified | Fuzz the request decoder (every PR) — 20k mutations of a seed corpus against parse_request_headers, the loop's framing decision (B29) and the chunked decoder (B30), replayable from the seed it prints; asserts determinism, that an INVALID request cannot become valid by appending, that a parsed one is unchanged by bytes after it, and that the decoder's counts index its buffer. poe fuzz-request-long is the release sweep (8 seeds x 250k). The run refuses to pass on thin coverage, and Sabotage the decoder invariants the fuzzer checks breaks each invariant so "no findings" cannot mean "checks nothing" |
| G14 | A request carrying bytes that are not UTF-8 — in the target, a static-mount path, a cookie, Accept, If-None-Match, Range or a JSON body a number is read from — is answered, never trapped, and so is one whose bytes come back out in a RENDERED FRAGMENT an application broadcasts: every slice of such a string on the serving path is a byte-span slice, which has no codepoint-boundary assert |
verified | test_unquote.mojo:test_invalid_utf8_beside_an_escape_does_not_trap (every PR) — unquote sliced its input as a String and one GET /?x=<0x80>%41 killed the process on the loop thread, every app alike; the cookie jar (parsed for every request), the static mount, the negotiator and the ETag matcher had the same shape (and the negotiator, the static mount's Range and the WebSocket Upgrade folded such bytes into ASCII with Unicode String.lower(), an overlong C1 A2 read as b, so a request matched a vendor type it never named; they fold in ASCII now, test_content_negotiation.mojo:test_a_media_range_is_folded_in_ascii_only, review record LF63), and so did the SSE line splitter and frame decoder, which bytes from outside the request reach too — a --pg-listen payload from a database that converts nothing, trapping worker 0's listener thread — each pinned by a test in its own file that declares this row, and a trap ends the test process, so the gate is those files passing at all. m0-datastar's split_data_lines is a deliberate copy of that splitter, kept dependency-free rather than imported, and needed the same fix one release later — reached not by a request directly but by a RENDERED FRAGMENT, which is where an application puts request data: a todo whose text carries a newline then a lone 0x80 byte, HTML escaping having left both alone, trapped the LOOP thread and killed the whole server on one unauthenticated POST. Since B19 a request TARGET holding a byte above ASCII is refused 400 by the target scanner before any slice, so the target's sites — unquote reached from the target, and the absolute-form reduction — are unreachable from the wire rather than gated there: Smoke test the hello server sends four such targets and requires the 400 for each, and the cookie jar's raw byte beside them and requires an answer. The decoder's wire case is a form body, title=<0x80>%41, which Smoke test the fragment notes app posts and requires answered with the server still up; Smoke test the notes API reaches the static mount with %80, which decodes to the byte, and the header sites with the raw byte, and requires the same; and Smoke test the Datastar todo demo posts such a todo and requires the server to answer it, stay up and keep rendering. m0-core's parse_json_number cut the number out of a body with a String slice, so {"x":1<0x80>} trapped; no app called it until apps/blobs' drop view, and Smoke test the blobs demo posts that body and requires an answer and a healthy server |
| G15 | SHA-256 and HMAC-SHA256 in m0-core, with a constant-time tag compare: FIPS 180-4's examples and the empty message, every split of one message across update calls, and RFC 4231's seven cases including the truncated one and the two whose keys exceed the block size; HmacSha256 absorbs its padded key once and finishes each message from a copy, and verify refuses a truncated tag |
verified | test_hmac.mojo:test_rfc_4231_case_7 (every PR) — the hash's own vectors are test_sha256.mojo:test_fips_180_4_examples; the first cryptographic primitive in the tree, and the one a signed grant and a signed session cookie (N13) rest on |
| G16 | Sockets the server creates are close-on-exec: a process the application starts with exec inherits no client connection and no internal channel | verified | Smoke test what a child process the application starts inherits (every PR) — a view starts a child with close_fds=False in six shapes (the pool, the loop, forked and spawned workers, --realtime, the ASGI executor) and requires it to hold no descriptor above 2: the server started with none there, so each would be its own. Before the fix every shape failed, the child holding 8 to 46 of them: connections, the listener, every channel, the shutdown pipe, the shared page. test_cloexec.mojo holds each creation helper, including those no shape reaches (the per-loop listener dups and fan-out pipes of --threads), and test_respawn.mojo that the spawn's own exec keeps exactly what the new image adopts. On macOS each creation call (accept, socket, socketpair, pipe, recvmsg) is followed by a second that marks the descriptor, so a fork and exec on another thread in the instant between can still inherit that one, as under CPython there; Linux creates each close-on-exec atomically. A child that forks WITHOUT exec (os.fork, multiprocessing's fork start method) inherits everything, which no descriptor flag can prevent |
| G20 | The shared page the workers map is created with mode 0o600, its owner's alone, as shared_file_fd asks: on Darwin arm64 shm_open's mode, a variadic argument, goes on the stack where the callee reads it, as fcntl's argument does; and a raise after the open closes the descriptor |
verified | test_cloexec.mojo:test_the_shared_page_is_its_owners_alone (every PR) — it bites on the macOS leg only, Linux passing variadic arguments in registers; test_a_page_that_cannot_be_sized_is_closed holds the close. Before the fix (review record LF17) the mode went as a third fixed argument, in a register the callee never reads, and the page took its mode from the stack: measured as 0o0, 0o1 and 0o744 |
| G24 | No byte string, whatever its UTF-8 shape, puts CR, LF or NUL inside a head line: every header name and value, reason phrase, Server value, Set-Cookie line and reply.redirect target is written by encode, encode_into and the printed form as exactly the lines G2 and G19 give, or dropped as G2 says |
verified | test_header_bytes.mojo:test_random_bytes_never_put_a_line_break_in_a_head (every PR) — G2 and G19 as a property: 4000 seeded cases a run (the seed is printed, and M0_HEADER_BYTES_SEED replays one), made of bytes biased toward every UTF-8 lead, lone continuations and the continuation bytes that decode to CR, LF and NUL in an overlong form. The oracle is a model of the rules, not an absence of CR: the head must hold, line for line, the lines the rules keep (a header or cookie with a raw break in what was given is dropped, and so is a built cookie whose value holds a ; or another control byte, G2's rule for built cookies since review record LF55; any other is written as given with only C2/C3 plus a continuation turned into one latin-1 byte), the body after it must be the body given, and no line may hold CR, LF or NUL, so an extra line, an altered one and a line lost that the rules keep all fail, as does dropping everything. poe sabotage-header-bytes puts back LF1's decode with and without the backstops, each writer's refusal, LF55's built-cookie drop, the redirect's percent-encoding and the emptied reason phrase, and requires this test to fail for every one |
| G21 | A recv or send the server makes moves the bytes of the span it is given and no others: the length is the span's own, so no caller can name more than its buffer holds, and a read into a list's spare capacity takes that room and no more (spare_capacity) |
verified | test_socket_spans.mojo:test_recv_writes_only_inside_the_span_it_is_given (every PR) — with test_send_sends_only_the_span_it_is_given and test_spare_capacity_is_the_room_past_the_length beside it. Both calls took a length beside the span that the span did not have to back, and the loop's body, WebSocket and lingering reads passed an empty span with a capacity count; a count above a span's length was written past its end, measured on the old signature as 64 bytes received into a 10-byte span, 54 of them past it (review record LF20). Each half sabotaged fails its own test: recv or send taking 16 bytes more than its span, and spare_capacity starting at the list's first byte |
| G25 | No variadic libc function is called through external_call outside its sanctioned wrapper, and neither the fork nor m0-http's src/ holds a [byte= slice outside a reasoned allowlist: Darwin arm64 passes variadic arguments on the stack, so fcntl and shm_open take _fcntl's shape (dummies fill x0-x7) in one file each and syscall is Linux's alone, and a [byte=a:b] slice asserts a codepoint boundary, a trap on a request's non-UTF-8 bytes |
verified | Run the shim tests and the compile gates (every PR) — poe fork-lint reads every .mojo under packages/, apps/, scripts/ and packaging/ for a variadic symbol in scripts/fork_lint.py's VARIADIC list (the list lives in the lint; the sanctioned files are its SANCTIONED, and c/fcntl.mojo's docstring is checked to name each), a call whose symbol is not a string literal in any quoting style, and the fork and src/ for [byte= however spaced or split (ALLOWED_BYTE_SLICES: the constant hex tables and the static mount's configured prefix, keyed by file and line snippet, a stale entry refused); comments, docstrings and strings are not read. Its selftest plants a fixed-argument shm_open and a [byte= slice of a request string, each refused naming file and line, and passes a clean sample. Pure text, so the two defects of review records LF17 cannot return unseen; it does not see a variadic function reached through dlopen (m0-sqlite, m0-postgres) |
H. TLS
| id | capability | status | evidence |
|---|---|---|---|
| H1 | TLS 1.2 / 1.3 termination | out of scope | terminate at a proxy — gunicorn's answer, and the same one applies here |
| H2 | ALPN, SNI, mTLS, OCSP stapling, certificate hot-reload | out of scope | all follow from not terminating TLS |
I. WebSocket and Server-Sent Events
| id | capability | status | evidence |
|---|---|---|---|
| I1 | WebSocket handshake (RFC 6455), Sec-WebSocket-Accept |
verified | Smoke test the WebSocket echo demo (every PR) |
| I2 | Fragmented messages reassembled | verified | test_websocket.mojo:test_fragmented_message_assembles (every PR) |
| I3 | Ping/pong, and a server heartbeat on a cadence | verified | Smoke test the WebSocket echo demo (every PR) — and test_slot_lifecycle.mojo:test_a_heartbeat_rearms_first_writes_its_frame_and_ends_a_dead_stream: a WebSocket's heartbeat is a final two-byte ping, its timer re-armed before anything else, so a beat skipped for a socket lingering after its Close still leaves the next armed, and nothing is sent to that socket |
| I31 | A ping is answered by a whole pong, and a Close by its whole echo before the socket closes, even when the send buffer toward the client is full: what the kernel does not take goes out through the write-ready path, reads waiting until it has, rather than being cut or dropped (RFC 6455 §5.5.1, §5.5.2) | verified | Smoke test the WebSocket echo demo (every PR) — 40,000 pings with distinct payloads, sent unread until the server's buffer is full, must all come back as whole pongs in order, and the socket must then close cleanly. Before the fix (review record R6) the loop discarded the send's count: a reply the kernel took only part of lost its tail, and the next frame's header landed inside a pong's payload (cut at byte 115 of 125 on macOS and after one byte in the Linux container, followed by 0x8A 0x7D), and a reply refused whole was dropped. Sabotaged on both platforms both ways, the cut alone and the cut with the drop, and caught every run. A close echo is queued the same way, and the socket closes once it has gone out. The wire cannot force that case, the echo being the last reply, so a unit test arranges it: the loop's end of a real stream pair fills its send buffer to a one-byte refusal before the peer's Close is read, and the peer then reads the filler, the whole echo carrying its own code, and EOF. It runs twice: over the epoll-shaped model backend, and over each OS's own multiplexer (kqueue on macOS, epoll on Linux) with each step taken on the event the wait reports. Closing at once while the echo is owed, and dropping what the kernel refused, each fail both runs at an assertion: test_slot_lifecycle.mojo:test_a_close_echo_the_kernel_refused_goes_out_before_the_close, test_slot_lifecycle.mojo:test_a_close_echo_waits_for_the_multiplexer |
| I32 | A stream's socket has TCP keepalive on, so a client that vanishes without a FIN from a stream with nothing in flight is reaped: after M0_STREAM_KEEPALIVE_S seconds of silence (15) the kernel probes the peer, at that interval, and fails the socket at the third unanswered probe, about a minute in all; the slot closes and the application's disconnect runs. It is set where a slot becomes an SSE stream or a WebSocket. The loop's heartbeat reaps a stream it writes (D57); the stream an application writes through the chunk channel gets no heartbeat, and no stream does with the heartbeat off, so there nothing else looked. The probe is an empty segment: no byte enters the stream, and a live client answers from its kernel, so an idle or slow reader keeps its slot. 0 turns it off |
verified | Smoke test keepalive on a stream's socket (every PR) — on both legs the kernel is asked what the server's own sockets carry (ss, or lsof -Tf on macOS): an application's SSE stream and a WebSocket show the timer at the default and at 1 s, a plain keep-alive connection none, and with the knob at 0 nothing does; a live client idle through 6 s of 1 s probes keeps its stream. The Linux leg drops a client's packets with iptables: the application must be told within 20 s (4.1 s measured), and with the knob at 0 must NOT be within 10 s. The setter is test_stream_keepalive.mojo:test_the_setter_turns_keepalive_on_with_its_three_timings, read back with option numbers written out a second time. Sabotaged: the call removed at either site fails the smoke naming that kind of stream; the switch removed, a wrong option number, or the count taken from the interval fails the test |
| I4 | Close frame echoed with its code, connection marked for close | verified | test_websocket.mojo:test_close_is_echoed_with_code_then_closes (every PR) |
| I5 | Invalid UTF-8 in a text frame closes 1007 | verified | test_websocket.mojo:test_invalid_utf8_text_closes_1007 (every PR) |
| I6 | A fragmented control frame is a protocol error | verified | test_websocket.mojo:test_fragmented_control_frame_is_protocol_error (every PR) |
| I34 | A message parsed from the same read as a protocol violation is still delivered, and a ping in that read still answered, the 1002 Close going out after its pong: what the application receives does not depend on how TCP split the bytes | verified | test_websocket.mojo:test_a_message_before_a_violation_is_delivered (every PR) — with test_a_pong_before_a_violation_goes_out_ahead_of_the_close beside it. Before the fix (review record LF9) a refusal answered with a fresh result, dropping both, while a peer's own Close kept them |
| I35 | A client frame whose 64-bit payload length has its most significant bit set closes the connection with 1002 (RFC 6455 section 5.2), not 1009 | verified | test_websocket.mojo:test_64bit_length_with_the_high_bit_set_is_protocol_error (every PR) |
| I36 | A client frame whose payload length is not in its minimal encoding (a 16-bit field holding under 126, a 64-bit field holding 65535 or less) closes with 1002 (section 5.2); the boundaries 125, 126, 65535 and 65536 are accepted | verified | test_websocket.mojo:test_non_minimal_16bit_length_is_protocol_error (every PR) — with test_non_minimal_64bit_length_is_protocol_error and the accepted boundaries in test_minimal_lengths_at_the_boundaries_are_accepted |
| I37 | A control frame declaring more than 125 payload bytes closes with 1002 as soon as its header is read, before any payload arrives and whatever the message cap (section 5.5), so it is never a 1009 | verified | test_websocket.mojo:test_oversized_control_frame_is_refused_at_its_header (every PR) — with the 16-bit and 64-bit length forms over the cap beside it, and test_control_frame_of_exactly_125_bytes_is_accepted against over-rejection |
| I38 | The upgrade's Connection header is matched as a token list, so notupgrade is refused and keep-alive, Upgrade accepted, its Upgrade is websocket in ASCII case folding and nothing else, and an HTTP/1.0 upgrade request is answered 400 (section 4.2.1) |
verified | test_websocket.mojo:test_connection_token_is_matched_whole_not_as_a_substring (every PR) — with test_http_1_0_upgrade_is_400, and test_the_upgrade_token_is_websocket_in_ascii_case_only: Upgrade was compared after Unicode String.lower(), which reads KELVIN SIGN (U+212A) as k and an overlong C1 B7 as w, so websoc<U+212A>et was upgraded, 101, where every other hop reads an unknown protocol, and a 101 naming it switched the connection (review record LF63) |
| I7 | Wrong Sec-WebSocket-Version answers 426 advertising 13 |
verified | test_websocket.mojo:test_wrong_version_is_426_advertising_13 (every PR) |
| I8 | Cross-worker WebSocket fan-out over the broadcast bus | verified | Smoke test the WebSocket chat demo (every PR) |
| I9 | Server-Sent Events, with heartbeats and disconnect cleanup | verified | Smoke test the Datastar counter (every PR) — and, on the loop, test_slot_lifecycle.mojo:test_a_heartbeat_rearms_first_writes_its_frame_and_ends_a_dead_stream (the : heartbeat comment, re-armed first; a client gone without a word closed by the send that fails, its handler told; a beat for a descriptor that holds no stream retired), test_a_closed_stream_tells_its_handler_once_and_retires_its_timers (every close of a stream tells the handler once, and takes its body timer and heartbeat with it) and test_a_frame_whose_write_wait_is_refused_closes_its_stream (a frame the kernel takes in part waits for writability, and when the backend refuses that registration the slot closes and its handler is told, from the heartbeat, the outbox drain and the WebSocket reader's reply alike; the first two used to leave the slot with its frame half sent, where the reader closed it — review record 605) |
| I10 | Last-Event-ID replay from a bounded journal |
verified | Smoke test the Datastar todo demo (every PR) |
| I11 | A synchronous view gating a held SSE connection, with cross-worker publish | verified | Smoke test the Django realtime example (every PR) — --realtime |
| I12 | A synchronous view gating a held WebSocket it never speaks; the socket is subscribed at the upgrade's Last-Event-ID clamped to the head, whether the hold is taken inline or forwarded from a pool thread as an H frame, so an id from a previous incarnation suppresses nothing (a socket is not caught up, so nothing is replayed) |
verified | Smoke test the Django realtime example over WebSockets (every PR) — the clamp, both ways a socket hold lands, is test_hold_resume.mojo:test_a_socket_held_inline_is_clamped_to_the_head and test_hold_resume.mojo:test_a_socket_held_on_a_pool_thread_is_clamped_to_the_head: an id of 999999999 against a counter at 5, and the next frame required |
| I18 | N holds taken AT ONCE from a pool, and a publish reaching all of them | verified | Concurrent --realtime holds from a pool (every PR) — the other realtime rows subscribe sequentially, so this is the only one where more than one hold is in flight; the probe's own self-test gates it, because the result it reports is a negative |
| I19 | Inbound WebSocket data larger than one socket read | verified | Inbound WebSocket messages survive a stalled client (every PR) — the WebSocket read path took one recv per event with no re-arm, A13's defect in the one path nothing had sent a large inbound burst to; invisible on kqueue's level trigger, and on epoll only once the client STOPS sending |
| I13 | Autobahn|Testsuite conformance run, wired to a cadence | verified | autobahn (pre-release) — sections driven separately against the runner's pure-echo ASGI app, image version-pinned so the per-section case counts are asserted exactly (a section wstest cut short at a connect it could not make, with the server alive and answering, is resumed on the same server from the cases not reached, up to three times, and the attempts together must score each pinned case exactly once; a fourth drop, a case scored twice, or a thin attempt with any other cause fails); compared both directions against the pinned baseline (240/247, every failure I17's cap): a new failure is red, and an I17 case unexpectedly passing is red too. The comparator's selftest runs first. What the suite cannot see stays L15's territory — its client always closes first (ROADMAP: A conformance-suite tier) |
| I14 | permessage-deflate |
out of scope | follows from having no response compression |
| I15 | WebSocket over HTTP/2 (RFC 8441) | out of scope | follows from having no HTTP/2 |
| I16 | A Close frame's code is VALIDATED, not just echoed | verified | test_websocket.mojo:test_reserved_close_codes_are_refused_1002 (every PR) — with test_legal_close_codes_are_still_echoed as the other half, so a refusal that refuses everything cannot pass |
| I17 | A message at or above the outbox cap ends the connection | verified | Smoke test the outbox cap ending a connection (every PR) — the marker sent after the oversized message must never arrive, which is what separates ending the connection from dropping a frame the peer cannot know it missed; the under-cap half stops a server that ended every large-message connection from passing. Deliberate, and what Autobahn scores as 7 failures plus all of its performance section. poe sabotage-outbox-cap (pre-release) reverts each of the four rules |
| I20 | A synchronous Flask view gating a held SSE stream and a WebSocket, with cross-worker publish | verified | Smoke test the Flask realtime views (every PR) — the headline names Flask, and K10 only proves plain WSGI. The file under test is extracted from docs/QUICKSTART_NEXT.md's own fenced block, so it is the one a reader types; the Django rows' RFC 6455 probe drives it unchanged (its gate phase off: the quickstart's views take no token), one stream and one socket pinned per worker under --workers 2, so a message sent on one worker's socket reaches a Flask view and comes back on the other worker's. The quickstart runs the same file from the wheel with curl alone (M10) |
| I21 | A grant-verified hold mount: --mount PREFIX=hold holds an SSE stream on a Mojo pool thread against a grant the application signed into the stream URL (m0serve.grant: version, key id, expiry, channel, session binding, HMAC-SHA256 tag) — the signature checked in constant time, expiry against the host clock, the session cookie the browser sends against the binding, the previous key accepted while M0_GRANT_KEY_PREV lists it, a bound grant refused without its cookie and an unbound one admitted, every refusal a 401 naming expired or invalid and its reason; a hold mount refuses to start without --realtime or M0_GRANT_KEY, and --doctor agrees |
verified | Smoke test the grant-verified hold mount (every PR) — the verifier's unit form is test_grant.mojo:test_a_grant_the_issuer_signed_admits_its_session, against grants the same issuer signed with a fixed key and clock; the design is grant-verified-holds |
| I22 | A Postgres NOTIFY reaches a held stream: --pg-listen URL holds one LISTEN on 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; a notification naming another channel reaches nobody, a malformed payload — including an event or data present as anything but a JSON string, such as the object row_to_json(NEW) makes — is refused and counted rather than delivered somewhere or delivered empty, exactly one listener runs under N workers so an event is delivered once rather than N times, a host with no libpq is a refusal rather than a server that runs with no listener, and without the flag neither does one naming the held channel | verified | Smoke test a Postgres NOTIFY reaching a held stream (every PR) — three arms, because the positive one alone passes against a server that published everything to everyone: the held channel arrives, another channel does not, a payload with no channel, no JSON at all, or a data that is a JSON object (which a string reader answers as empty, and which reached subscribers as an empty data: line counted as delivered until it was refused — measured on the gate before the fix, and pinned per shape without a server by test_pg_envelope.mojo:test_data_that_is_not_a_string_is_refused in poe test-all) is counted as refused while the listener survives to deliver the next good one (asserted as a COUNT of events on a guarded channel, because a by-name check passes against a refusal that delivers an empty event — measured), the same publish against a server started without the flag reaches nobody, and a two-worker server starts exactly one listener and delivers one copy — in each platform's own worker shape, forked on Linux because that is what a deploy runs, spawned on macOS because a forked child there is refused. --pg-listen without --realtime exits 2 naming what is missing, since the flag creates the bus there would otherwise be nothing to publish into; on macOS it also exits 2 wherever a worker is FORKED — --workers N, and --reload, which supervises even one, and which the refusal first missed because it tested the worker count, so the gate runs both shapes through the server and the doctor on macOS and asserts the doctor refuses neither on Linux — because libpq's connect reaches Kerberos through GSSAPI and Objective-C aborts a forked child — measured as the worker killed by signal 9 and respawned until the supervisor gave up, which is the churn CLAUDE.md records for _scproxy. Both refusals run before the bind and the fork, where the supervisor can report them; placed after it they ran in every child and none in the parent, so a usage error read as a crash loop. A host with no libpq exits 78 (sysexits' EX_CONFIG) naming every path tried, which the WHEEL's own binary is held to by Build and smoke test the installable wheel — the artifact whose users have neither a toolchain nor a libpq, and the place an absent library would otherwise degrade into a listener that silently hears nothing. In the Linux job with a service container, GitHub's service containers requiring a Linux runner. The gate exercises the prefork path; --threads starts the same listener once per process and is NOT gated, because CI pins a GIL-enabled interpreter and the mode refuses to start on one — verified by hand on free-threaded CPython 3.14.7 on 2026-09-12, a NOTIFY reaching a stream held across two loops, and confirmed to have been a silent no-op before it was wired into that path |
| I23 | A child process a view starts can publish onto the bus: m0pub.child_fds() passed as pass_fds hands it the bus and the event-id page by descriptor, and its frames are numbered from the shared counter; a child that inherited the environment without the page, or with an unrelated file on the page's old descriptor number, publishes unnumbered frames and never takes an id from memory that is not the page | verified | Smoke test publishing from a child process (every PR) — bareapp.childpub under --realtime with one worker, --workers 2 and --spawn-workers: a child given child_fds() must take an id strictly between two the parent takes around it and carry it on the SSE frame; children that inherited everything, had M0_SHARED_ID_ADDR removed, or found a file of the page's size on M0_SHARED_ID_FD must exit 0 and publish unnumbered, the last with that file unwritten; a child handed no bus must write nothing into whatever sits on a M0_BUS_WRITE_FDS number -- an unrelated file, left unwritten, or its own datagram socket, which receives nothing and still blocks -- and malformed numbers degrade to nothing written. m0pub keeps a number only when os.fstat finds the device and inode M0_BUS_WRITE_IDS records for it: the bus is close-on-exec since G16, and before this m0pub wrote into the file, and a check by kind wrote into the socket and made it non-blocking. Before the fix the inheriting child died with SIGSEGV (one worker) or published an id taken from its own memory (the other two shapes), measured on macOS; #322 reported the SIGSEGV on Linux. Reverting the magic check at the address, the kernel readability check, the magic check on the descriptor, the page in child_fds(), the server's magic word or the file-backed page each fails it |
| I24 | A stream of states: DatastarStream(send_latest=True) keeps the newest frame per url by event id — from a local broadcast or from the bus — and sends it to every subscriber at open, first visit and reconnect alike, then the live feed; it never replays, and it ignores the Last-Event-ID a client presents, because a producer's ids restart with its process and comparing would park a tab from the previous process until the new ids passed its old one; an older frame arriving late neither replaces the newest nor reaches a subscriber that has it | verified | test_stream.mojo:test_state_stream_sends_the_newest_frame_at_open (every PR) — its control is the default stream fed the same frames, which sends nothing at open; test_state_stream_ignores_the_presented_id presents an id behind, level, far ahead and malformed; test_state_stream_keeps_the_newest_by_id delivers frame 2 after frame 3. Sabotaged four ways against those tests (no send at open, older replaces newer, the presented id compared, bus frames not kept), each caught. On the wire, Smoke test the blobs demo waits for its producer to pause so the newest id is fixed, then requires a new stream's first frame to be exactly that id and nothing older |
| I25 | A bus publish reports what it delivered: publish_to_channels (and BroadcastBus.publish) returns how many channels took the datagram — 0 for a frame over BUS_MAX_FRAME, a reserved channel or a name longer than the wire's two-byte length, and fewer than addressed when a channel is full — so a producer whose frame grows with its state can count a refusal that is otherwise indistinguishable from a producer that stopped | verified | test_broadcast.mojo:test_publish_reports_what_it_delivered (every PR) — the partial case stuffs one of three channels until it refuses and requires the next frame to count two; counting before the send instead of after it is caught there. Smoke test the blobs demo requires its producer's refusal count to stay 0 at a full stage |
| I26 | An inbound WebSocket message larger than the channel's datagram is refused with a Close carrying 1009, logged once, never parked | verified | Conformance test the ASGI bridge (every PR) — a 70,000-byte message to an executor-held socket must be answered with a 1009 Close within 5 s and the application must hear 1009, while a 65,536-byte one is still delivered whole; Smoke test the Django realtime example over WebSockets sends the same to a pool-held socket and requires the connection to END on the client's reply, with no second Close (Chromium reports a second one as a failed connection, 1006), and within the linger when the client never replies; a message sent right behind the refused one reaches no one (RFC 6455 §7.1.7). Before, the message was parked and retried for ever: no reply, no close, no log |
| I27 | Datastar frames are the SDK's own: every conformance case the Datastar SDK ships at the version m0-datastar pins — its get-cases and its post-case, vendored byte for byte with each file hashed — is produced by the package's builders and judged by the SDK's own compare-sse.sh, which lets datalines of different keys arrive in any order and nothing else: multi-line signals go out one dataline per line, remove mode carries no elements line, and execute_script writes its attributes | verified | Run the shim tests and the compile gates (every PR) — poe check-datastar-sdk, inside test-all, refuses fixtures from any tag but the one consts.VERSION pins and any vendored file whose hash has changed, and fails by name on a fixture field it does not map; poe sabotage-datastar-sdk, inside test-all too, reverts each of the three rules in a copy of the package and each is caught by the case it breaks. Before the gate the package passed 14 of the 19 get-cases |
| I28 | read_signals reads the signals where the client sends them: the datastar query parameter on GET and DELETE — the two methods Datastar's bundle sends without a body, and the SDK's ReadSignals table — and the body on every other method; each SDK case's input comes back byte for byte as a GET, a DELETE and a POST | verified | test_stream.mojo:test_read_signals_from_delete_query (every PR) — a DELETE's query is read and its body ignored, and PUT, PATCH and QUERY read the body; poe check-datastar-sdk replays every case's input through all three methods, and its sabotage reverts DELETE to the body and loses 19 of 20. A DELETE's signals came back as {} before |
| I29 | A Datastar frame cannot be split by what it carries: a selector, mode, namespace, view-transition selector or event id holding CR or LF is refused with an error rather than written — two breaks would end the event and open one of the sender's choosing — and redirect writes its location as a JavaScript string literal with the quote, the backslash, <, every control byte and U+2028/U+2029 escaped, so it can end neither the literal nor its <script>; and it refuses a location that is neither an http/https URL nor a reference relative to the page that stays on its site, since javascript: assigned to window.location runs in the page's origin and //host leaves it, reading the location as the browser's URL parser does, so a leading space or control byte, a tab or line break inside the scheme, the scheme's case and a backslash for a slash are refused with the plain forms | verified | test_frame_injection.mojo:test_a_line_break_in_a_single_line_field_is_refused (every PR) — every field through every builder, the event-injection shape among them, with a control that a break-free selector still goes out; test_redirect_location_cannot_leave_its_literal requires exactly one </script> in a frame whose location carries two; test_redirect_refuses_a_location_that_runs_script_or_leaves_the_site refuses nine spellings of a scheme and six of another host, each of which the WHATWG URL parser resolves to a scheme other than http(s) or to another host, beside eleven locations that go out, and test_stream.mojo:test_a_refused_redirect_reaches_no_subscriber holds redirect_to to the same rule with nothing queued. poe check-datastar-sdk holds the location check permanently too: its generated program sends a table of locations through redirect — script-running schemes, spellings of another host and controls — and requires each refused for its own reason or sent. poe sabotage-datastar-sdk reverts the refusal and three of the escapes, each caught by its unit test, and the location check whole and each of its two refusals, each caught by that table |
| I30 | A reconnect the replay journal cannot fully catch up is told so, never served in part: when the client's Last-Event-ID is older than a frame the journal evicted or than the process's own history — its first restored frame, or what siblings sent before a respawned worker joined — or is ahead of every id the process knows, or when the frames it missed will not fit the connection's 64 KB outbox, DatastarStream.open queues none of them and caught_up(slot) answers false; send_to(slot, frame) queues one unnumbered frame for that connection alone, and apps/datastar_todo sends its current list that way. Live frames a full outbox refuses are counted (refused()) | verified | test_stream.mojo:test_a_replay_larger_than_the_outbox_is_not_served_in_part (every PR) — thirty 3 KB frames used to arrive as frames 1–21 with 22–30 refused, leaving the client nine changes behind with a clean log; now nothing is queued, the resync arrives, and the live feed resumes at 31. Beside it, one test per source of a gap and a control that a replay which fits is whole; poe sabotage-datastar-sdk reverts the rollback, the eviction and history checks, the ahead check, the late worker's floor and the refusal count, each caught. On the wire, Smoke test the Datastar todo demo reconnects thirteen 2 KB adds behind and requires the newest todo and no replayed id; with the rollback reverted it fails there |
| I33 | A held stream that reconnects with Last-Event-ID is caught up: each loop journals the last --replay-frames numbered frames it delivered (M0_REPLAY_FRAMES; 64; 0 keeps none), and a WSGI hold — subscribed inline or forwarded from a pool thread as an h frame, which is also how a Mojo mount's and a hold mount's arrive — is sent every frame of its channel after the id it presented, in order and ahead of the live feed. All or nothing, as I30: an id below the journal's floor (a frame evicted, or published before a respawned worker joined, the floor being one number for every channel so a server with a channel per visitor keeps nothing that grows), frames that would not fit the outbox, or an id ahead of the counter (a previous incarnation's, clamped to the head so it suppresses nothing) get no history and one unnumbered event: m0-gap frame naming the id presented and the head, then the live feed. A first visit gets the live feed alone | verified | Smoke test the Django realtime example (every PR) — phase 3b, once against the default pool and once under --blocking-threads 0: two frames missed and replayed in order ahead of a live one with another channel's left out, a client pushed past a 4-frame journal told so and served nothing from history, a stale id told so and not starved. test_replay.mojo holds the journal's rules on their own, and test_hold_resume.mojo:test_a_resume_from_before_a_late_worker_joined_is_a_gap, with test_hold_resume.mojo:test_a_resume_at_the_counter_is_caught_up_with_nothing and test_hold_resume.mojo:test_an_id_the_bus_has_not_delivered_yet_is_not_clamped, the handler's two reads of the shared counter, which one worker cannot tell from the journal's own head: the floor a late worker starts at, and the head a resume is judged against |
| I43 | A replay journal deeper than 4096 frames is refused with 78 before the bind and the fork, and --doctor refuses it with the same code, whether the depth came from --replay-frames or M0_REPLAY_FRAMES and whether or not --realtime is given, though only --realtime keeps a journal: there each 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. 0 and 4096 are accepted. The cap follows the rules a flag combination can fail, so a configuration that also cannot be read is refused for that, with 2 | verified | Smoke test --doctor against the server's own exit codes (every PR) — 4097 by flag, 100000 by flag under --realtime and 4097 by M0_REPLAY_FRAMES, each required to agree with the doctor and to exit 78, and 4096 serving; 5000 beside --pg-listen without --realtime is the usage refusal's 2. The unit half is test_checks.mojo:test_a_journal_deeper_than_the_cap_is_refused_with_78 |
| I39 | An inbound WebSocket message for a socket a pool thread's view held reaches a thread of its lane even when it lands while none is parked: the wake send_ws_message sends finds only a thread already parked, so a thread that parks after its last poll of the lane socket looks at the socket again once it has announced the park, and takes the message there rather than leaving it on the socket until a later job wakes the lane | verified | test_offload.mojo:test_a_websocket_message_sent_while_the_thread_is_on_its_way_to_park_is_taken (every PR) — the lane's one thread is inside a 200 ms view when the message is sent, waking nobody, and the lane's poll clock is moved past the end of the test, so the only look at the socket is the one the park takes; the thread must have taken the message by the time it is pilled. Before the fix (review record LF22) a sleep held open the window between a thread's last poll and its announcement, and 24 messages of 24 sent inside it were left on the socket, the last until the pill; with the fix, 0 of 24. The parked case is test_a_websocket_message_wakes_a_thread_parked_on_its_own_channel, beside it, which waits for the message's delivery. It waited for the parked count's dip, which lasts only until the woken thread parks again, and once timed out at its 2000 ms bound on a loaded machine with the message served (review record LF68: a 5 ms sleep between the send and the test's look failed it in 5 runs of 5, and 5 ms sleeps at four points of the pool's park path in none) |
| I40 | A bus datagram is delivered whole or not at all: the loop reads every datagram a publisher can send whole, up to BUS_DATAGRAM_MAX (a channel name of MAX_CHANNEL bytes and a frame of BUS_MAX_FRAME, 131,081 bytes), into one buffer it keeps for its life, and a longer one -- cut by the kernel, or longer than any publisher sends -- or a malformed one is refused and counted, http_bus_frames_refused_total on /__metrics (the stream chunk channel, which an executor or a streaming pool writes in the same codec, is drained by the same reader and counted with it), while the drain goes on to the next; an empty datagram, which no publisher sends and which is also how macOS's recv reports a datagram pair's end, ends the drain uncounted | verified | test_broadcast.mojo:test_a_long_channel_with_a_whole_frame_arrives_whole (every PR) — a 5,000-byte channel with a 64 KB frame and the largest datagram of all arrive whole, last byte included; test_a_datagram_longer_than_any_publisher_sends_is_refused_and_counted (one the kernel cut, one a byte too long that it did not, and one too short for the header, each refused and counted, the frame behind them delivered) and test_the_loop_reports_a_refused_bus_datagram_on_its_metrics (the loop's own reader, its count on /__metrics) beside it. Before the fix (review record LF23) the drain read into a 69,642-byte buffer allocated and zero-filled for every drain, and the 5,000-byte channel's frame reached its subscribers 64,632 bytes long. The length a cut datagram returns is the buffer's on macOS and on Linux alike (measured in the colima VM, kernel 6.8), so a datagram that fills the one-byte-longer buffer is the refusal on both, where MSG_TRUNC passed to recv reports the real length on Linux and nothing on macOS. Sabotaged four ways (the old buffer, no length check, no count of a malformed datagram, the count not reaching the metrics), each caught |
| I41 | A WebSocket whose inbound the application has suspended stays suspended through this side's close: the socket lingers for the peer's Close without reading until the application resumes it, or the linger's deadline ends the wait | verified | test_slot_lifecycle.mojo:test_a_close_landing_leaves_a_suspended_sockets_reads_alone (every PR) — over a registration kept as epoll keeps it, a suspended socket's Close lands through the write-ready path and the linger adds no read interest; one not suspended lingers reading. _ws_linger armed the reads _stream_idle refuses a suspended socket, so the socket undid its own backpressure and the parked queue grew with what the client sent next. Found by the 2026-10-07 fork review |
| I42 | A fragmented WebSocket message reaches the application in the buffer it was assembled in, every byte in place, rather than copied out of it, and a socket's parser keeps none of its buffers once the socket closes | verified | test_websocket.mojo:test_an_assembled_message_is_moved_out_whole (every PR) — three fragments, the last split across two reads, arrive whole, and the message must sit at the address the parser assembled it at, the parser keeping no capacity after; test_a_closed_sockets_parser_lets_go_of_its_buffers beside it resets a parser holding a message mid-assembly and a frame mid-arrival. Before the fix (review record LF25) the message was copied out and the original cleared, which kept that message's capacity, up to the message cap, until the next fragmented message on the slot; and closing the socket cleared both buffers the same way, carrying them into the slot's next socket until its first fragmented message or first whole frame replaced them. Each half reverted fails its test at an assertion |
J. Static file serving
| id | capability | status | evidence |
|---|---|---|---|
| J1 | Zero-copy sendfile, body never entering the process |
verified | Smoke test zero-copy static file serving (every PR) — --static |
| J10 | A file response whose head the socket cannot take at once still sends its file: the write-ready path that finishes the head pumps the file after it, as the eager path does | verified | Smoke test zero-copy static file serving (every PR) — 1000 requests for a small --static file pipelined on one connection to a client that reads more slowly than the server answers, each head made 12 KB by a long --static-cache-control so the send buffer fills at a head on both kernels, and every response must arrive with its own file. Before the fix a head that needed a second send went straight to the keep-alive reset with the file unsent, and the client read the next head where the body belonged: at response 37 on macOS and 173 in the Linux container, every run. With 5000-byte files and short heads, 19 of 78 stalls fell on a head over 20 MB, because XNU's sendfile refuses below the send low-water mark |
| J15 | A file body that ends before the length its head promised, as a static file truncated while it is served does, closes the connection at the next write event, the client reading EOF where the bytes ran out, rather than leaving its slot waiting for room the socket already has | verified | test_slot_lifecycle.mojo:test_a_file_that_ends_early_closes_its_connection (every PR) — over this OS's multiplexer, the file truncated mid-transfer where the transfer has got to; test_sendfile.mojo:test_the_end_of_the_file_is_a_failure holds the binding's answer. Before the fix (review record LF16) sendfile's end of file read as no progress: the write one-shot was re-armed and fired at once, pass after pass, until the idle timeout reaped the slot, and for good with idle timeouts off |
| J2 | Byte range served as 206 with Content-Range, its bytes= unit matched in ASCII case only |
verified | test_static.mojo:test_range_serves_206_with_content_range (every PR) — with test_the_range_unit_is_bytes_in_ascii_case_only: the header was lowered by Unicode String.lower(), which decodes an overlong C1 A2 as b, so <C1 A2>ytes=0-1 was served as a range where any other hop serves the whole (review record LF63) |
| J3 | Unsatisfiable range answered 416 carrying the total | verified | test_static.mojo:test_range_unsatisfiable_is_416_with_total (every PR) |
| J4 | If-None-Match takes precedence over a range |
verified | test_static.mojo:test_if_none_match_beats_range (every PR) |
| J5 | If-Range with a weak ETag serves the full body |
verified | test_static.mojo:test_if_range_with_weak_etags_serves_full (every PR) |
| J6 | ETag and conditional 304 | verified | Smoke test the notes API (every PR) |
| J7 | Cache-Control, configurable |
verified | Smoke test the serve CLI (every PR) — --static-cache-control |
| J11 | --static-header 'Name: value', repeatable, adds a header to every response a static mount answers, errors included, and never replaces one the response sets itself; refused at the command line (exit 2) for a name that is not a token, a control character, a name given twice, a name the static server sets, or Cache-Control, which --static-cache-control owns |
verified | Smoke test the serve CLI (every PR) — Referrer-Policy on a static 200, a 304 and a refusal's 404, and on none of the application's responses; Cache-Control refused with exit 2 naming the other flag; --doctor reporting the mounts, their Cache-Control and every static header as JSON that parses with a quote in a directory's name. The refusals one by one: test_cli.mojo:test_static_header_refusals |
| J12 | Every response a static mount answers carries X-Content-Type-Options: nosniff, with no opt-out, because the type always comes from the extension table and never from the bytes; a --static-header naming it replaces the value |
verified | test_static.mojo:test_nosniff_on_every_answer (every PR) — a 200, a 304, a 206, a 416, a 405 and a refusal's 404, with nothing configured; on the wire in Smoke test the serve CLI |
| J13 | A static file carries the type its extension names: the page, image, font, text, data, audio and video formats a browser displays or plays when opened directly, a proxy compresses by type, or an agent reads by type (.md as text/markdown); build inputs, model weights and JSON Lines stay application/octet-stream |
verified | test_static.mojo:test_every_listed_type (every PR) — every listed extension in lower and upper case, the unlisted ones pinned by test_static.mojo:test_build_inputs_weights_and_jsonl_stay_unlisted, and the docs site's Markdown twins answering text/markdown in Smoke test the documentation site. An image or an audio file of a type the table lacked downloaded when opened directly, in Chromium, Firefox and WebKit, where Starlette and Django serve it to be displayed or played; the list was drawn against Python's mimetypes, which both use |
| J14 | An SVG a static mount serves is answered as a document that may not run: its 200, 206 and 304 carry Content-Security-Policy with default-src 'none' and sandbox, so its script cannot act as the serving origin when the SVG is opened directly or framed, while an <img>, a CSS background and a <use> sprite render as before; nothing else carries it, and a deployment's own Content-Security-Policy (--static-header) replaces it on every answer |
verified | test_static.mojo:test_an_svg_is_answered_sandboxed (every PR) — the 200, the 304 and the 206, none on a stylesheet or a refusal's 404, and test_static.mojo:test_a_named_policy_replaces_the_svg_sandbox; on the wire in Smoke test the serve CLI, the SVG carrying the policy, the stylesheet beside it none, and --doctor reporting the named policy in its place. In browsers, poe browser-svg-sandbox (pre-release): Chromium, Firefox and WebKit draw the three uses, judged by pixels, and a scripted SVG leaves no mark in the origin's storage framed or opened directly; with a policy that allows script, the probe sees it run both ways. Without the policy the script ran as the origin in all three, the stored XSS an uploaded SVG is |
| J8 | Response compression (gzip, brotli, zstd) | out of scope | recorded in ROADMAP as deliberate: no dynamic compression; a proxy compresses |
| J9 | Precompressed sidecar files (.br, .gz) |
out of scope | follows from the row above |
K. WSGI (PEP 3333)
| id | capability | status | evidence |
|---|---|---|---|
| K1 | application(environ, start_response) against a bare callable |
verified | Conformance test the WSGI bridge (every PR) |
| K2 | wsgiref.validate pass, with an engagement canary |
verified | Conformance test the WSGI bridge (every PR) |
| K3 | The write() callable reaches the client, in production order |
verified | Conformance test the WSGI bridge (every PR) |
| K4 | A second start_response, with and without exc_info |
verified | Conformance test the WSGI bridge (every PR) |
| K5 | wsgi.input read, readline, iteration, and read past EOF |
verified | Conformance test the WSGI bridge (every PR) |
| K6 | QUERY_STRING raw while PATH_INFO is decoded |
verified | Conformance test the WSGI bridge (every PR) |
| K7 | close() called on the response iterable |
verified | Conformance test the WSGI bridge (every PR) |
| K8 | Correct wsgi.multithread / wsgi.multiprocess for the real topology |
verified | Smoke test the Django WSGI example (every PR) |
| K9 | Unsized iterables streamed from a pool thread, sized bodies buffered | verified | Smoke test streamed WSGI bodies (every PR) |
| K10 | Framework-neutral: one contract, two frameworks | verified | Run the WSGI framework contract against Flask (every PR) |
| K11 | The hold headers degrade under another WSGI server: the same view answers a short plain response | verified | Execute the quickstart (every PR) — the quickstart's Django file under gunicorn: /events answers 200 with the view's body and closes inside curl's deadline (held, it would not), the upgrade request answers 200 rather than 101, and publish() reports 0 workers without raising. The README's "degrades, not breaks" sentence, executed |
| K12 | The gateway sends the application's response head as sent: no invented Content-Type, and the application's Content-Length kept on a HEAD |
verified | Conformance test the WSGI bridge (every PR) — scripts/head_probe.py reads the bare app's heads: a 200 and a 303 sent without a type, a bare 204 and one carrying a length and a body, a bare 304 and one naming the GET's length, and a HEAD answered with the GET's length and no body. Each must carry what the application sent plus the server's own framing and nothing else, and each is followed by a request on the same connection, which a leaked body would misparse. Conformance test the ASGI bridge runs the same cases through the executor and the buffered bridge. Before the fix every case failed: application/octet-stream on each head sent without a type, content-length: 0 on the HEAD, the 204 and the 304s |
| K13 | A HEAD to what a GET would stream is answered at the body's first item, with no length, and the body closed | verified | Smoke test streamed WSGI bodies (every PR) — a HEAD to a never-ending generator on a four-thread pool, then a request on the same connection, then five more HEADs and a fast request. HEAD used to take the buffered path, which joins the whole iterable, so the HEAD never answered and held its pool thread until shutdown; eight such requests are the whole zero-config pool. L27's WSGI twin. On the loop's own handler, which never streams, a HEAD still buffers and carries its measured length, as its GET does. poe test-shim pins that the body is pulled to one item and closed |
L. ASGI 3.0
| id | capability | status | evidence |
|---|---|---|---|
| L1 | Single app(scope, receive, send), protocol detected from the object |
verified | Conformance test the ASGI bridge (every PR) — --protocol |
| L2 | http scope shape, validated against the spec |
verified | Conformance test the ASGI bridge (every PR) |
| L3 | websocket scope: connect, accept, receive, send, close |
verified | Conformance test the ASGI bridge (every PR) |
| L4 | lifespan startup and shutdown, degrading if unsupported |
verified | Conformance test the ASGI bridge (every PR) |
| L5 | lifespan.state shallow-copied into each request scope |
verified | Conformance test the ASGI bridge (every PR) |
| L6 | Streaming responses stream, credit-gated per stream and in total | verified | Conformance test the ASGI bridge (every PR) |
| L7 | Slot ownership across recycled connections, sabotage-proven | verified | Run the shim tests and the compile gates (every PR) — poe test-shim drives the extracted shim through real socketpairs and reverts each rule |
| L8 | The event loop running inside asyncio (M0_INVERTED) |
verified | Smoke test the ASGI executor under the loop inversion (every PR) — including its drain, whichever of its passes reads the SIGTERM: a request raising SIGTERM in its first step, sent on a connection already answered, makes it the pass a completion flush runs, every time, and the process must answer it and exit 0. That flush had stored a copy of the executor's state taken before its pass back over the drain's record, and the server never exited (CI's macOS runners, about 2 in 100 runs of the live-stream shutdown). poe test-shim holds the shim's half: a drain the flush reports starts at once |
| L9 | Slot ownership under CPU contention, on the streamed AND WebSocket paths, in both loop modes | verified | stress-asgi (pre-release) — each round runs chunked_keepalive.py then ws_probe.py, so the handshake lands on the slot the streamed connection just released; run under CPU hogs on the pump and again under M0_INVERTED=1 |
| L10 | Django's own ASGI handler through the executor | verified | Serve a Django ASGI project through the executor (every PR) |
| L11 | Starlette-family app (FastHTML) through the executor | verified | Serve a FastHTML app through the ASGI bridge (every PR) |
| L19 | The same Starlette seam driven by FastAPI: routing, StreamingResponse, and a WebSocket the app closes itself |
verified | Serve a FastAPI app through the ASGI bridge (every PR) — the row L11 does not cover, because the two frameworks drive Starlette differently and only this one is named on the landing page. The streamed route is checked in the server LOG as well as the body: marking Starlette's anyio child task instead of the slot's owner left one traceback per streamed response with every body intact, so a green body proves nothing here. Request validation and the OpenAPI page are FastAPI's own to test and are deliberately not asserted |
| L12 | Cross-worker pub/sub as scope["state"]["m0"], whose publish numbers frames from the shared counter as m0pub does, the first id 1, so the two Python publishers on one server never take one id |
verified | ASGI cross-worker fan-out over the BroadcastBus (every PR) — fanout_probe.py requires a fresh server's first publish to be id 1, and an m0pub publish followed by a state["m0"] one to take two ids |
| L13 | http.response.pathsend |
out of scope | --static serves files in Mojo ahead of the application, which is the same saving without the extension |
| L14 | http.response.zerocopysend, early_hint, trailers |
out of scope | no application has asked; the extensions are additive and can be taken later |
| L15 | An app-initiated close ends in a FIN, not an RST (RFC 6455 §5.5.1's order) | verified | Conformance test the ASGI bridge (every PR) — ws_probe.py runs 64 concurrent app-initiated closes and requires every one to end in a clean FIN; concurrency is what widens the window, so one close at a time would pass on the broken server. One window no probe can force: an executor descheduled between the Close and the end marker that starts the wait had the peer's reply echoed as a second Close, on CI twice, so the executor sends the two as one datagram and test_stream_frames.mojo pins that shape |
| L16 | ...and the wait for the peer's reply is BOUNDED, so a peer that never answers does not hold its slot | verified | Smoke test the idle connection timeout (every PR) — the linger used to re-arm on every loop pass, which held the slot for the life of the process; L15 alone passes on that server. A socket whose Close goes out through the write-ready path keeps the linger it was given: the send deadline, which each send that moves bytes restarts, skips a stream; without the skip, a Close queued behind a large frame would linger for the idle timeout from its last progress instead: test_slot_lifecycle.mojo:test_a_send_deadline_leaves_a_websockets_close_linger |
| L18 | ASGI on a free-threaded CPython build is refused with exit 78, naming modular/modular#5726 | verified | py-canary (weekly) — smoke-django-realtime phase 6 on 3.14t: the executor's Python type cannot be built there (the stdlib lays PyObject out for the GIL build), so the mixed server exits 78 alone, through --doctor, and under --workers 2 without a respawn; on the pinned GIL interpreter the same phase runs the full mixed server |
| L17 | Inbound websocket.receive is flow-controlled: a stalled client is throttled, never silently dropped |
verified | Inbound WebSocket messages survive a stalled client (every PR) — the loop suspends the read rather than discarding what the executor cannot take; a concurrently-reading client loses nothing even on the broken build, so the gate stalls first and only then reads |
| L20 | A send is judged by the connection it addresses, never the task making it: one connection's task reaches another's live socket, and a send kept for a client that has gone never reaches the client given its slot | verified | Run the shim tests and the compile gates (every PR) — poe test-shim recycles a slot under a kept WebSocket send and under a kept stream send, with and WITHOUT a disconnect tag (a socket its application closed, which the loop never tagged), sends from a gone socket's own task to the sockets still connected, answers after the application returned, and sabotages each rule; FastAPI's documented chat room checks the same on the wire in L21's gate |
| L21 | A WebSocket's disconnect reaches the application through receive(), never as a cancellation, so its own cleanup runs; a send to a socket that has gone raises ClientDisconnected (an OSError) |
verified | Serve a FastAPI app through the ASGI bridge (every PR) — FastAPI's documented chat room: its except WebSocketDisconnect: must tell the room and shrink the connection list, a new client must receive one copy of a broadcast, and a socket the SERVER ends (one message over the outbox cap) must still reach its application; the two-executor case, where that disconnect is routed by lane, is a phase of the mixed-mount smoke |
| L22 | A response is answered at its final body; the application's background tasks run after it, and survive the client leaving | verified | Serve a FastHTML app through the ASGI bridge (every PR) — a BackgroundTask that sleeps 2 s under a response that must arrive in under 1 |
| L23 | An application error keeps the application's own error response, and its traceback reaches the log | verified | Serve a FastHTML app through the ASGI bridge (every PR) — the raising route must answer Starlette's own 500 body, and the log must carry the traceback |
| L24 | A WebSocket application that raises is closed with 1011, not 1000 (RFC 6455 §7.4.1) | verified | Serve a FastAPI app through the ASGI bridge (every PR) — the chat probe's raising-socket phase |
| L25 | A de-chunked request's scope carries content-length and not the chunked coding the loop removed, and no header the client did not send |
verified | Conformance test the ASGI bridge (every PR) — a chunked POST and a GET, read back from the scope on the executor and on the buffered hatch: the de-chunked one must carry its length and no transfer-encoding, and the GET neither a content-length nor a connection curl never sent. Before the fix every parsed request went through the outgoing constructor, which filled in a length, a Connection and a Host, so a GET carried content-length: 0 and connection: keep-alive and a chunked request both halves of a contradictory pair an application proxying it would forward. A gzip, chunked body never gets this far, refused 501 by the parser (B21), and HTTP/1.0 still closes by default, now read from the protocol (test_request_as_sent.mojo). The WSGI environ follows, since it maps the same headers: Conformance test the WSGI bridge sends the same two requests to /environ |
| L26 | An application that schedules asyncio work at import is refused by name, with the fix | verified | Conformance test the ASGI bridge (every PR) — a module calling asyncio.create_task at import must exit 1 naming what it did and the fix (a lifespan startup handler), keep its traceback and print no ready banner, and --doctor and a bare MODULE through discovery must say the same. uvicorn refuses the same module without --reload, as RuntimeError: no running event loop, which is all m0serve used to print. poe test-shim holds the other half: any OTHER RuntimeError keeps its own words |
| L27 | A HEAD answered by a streaming application is its head and nothing else: answered at its first streamed body, the rest dropped | verified | Conformance test the ASGI bridge (every PR) — a HEAD to a route that streams 10,000 bytes, then a request on the same connection, which must parse. Before the fix the executor streamed the HEAD like a GET, the loop wrote all 10,000 bytes raw after the head, and the next request found them where its response should begin. The application's later sends are dropped, and it is told its response is over: a receive() parked from before the first body (Starlette's, Django's) is woken with http.disconnect, which stops a StreamingResponse without cancelling its background work, and an application that parked none is cancelled if it is still running a second later. A 1xx, 204 or 304 streamed the same way is answered the same way. poe test-shim holds each of those rules, sabotage-proven. The loop holds the same line for a hold approved on a HEAD and for a native SSE route: Smoke test the Django realtime example and Smoke test the Datastar counter each send a HEAD to their stream, then a request on the same connection that must be answered, and require a subscriber count of zero after it. Under the buffered escape hatch a HEAD's stream is buffered like its GET's: a finite one is answered with its measured length and no body, an endless one with the watchdog's 500 after 10 s |
| L28 | A WebSocket's disconnect tells the application the code its client closed with: the client's own, 1005 for a Close with no code, 1006 only when no Close was parsed | verified | Conformance test the ASGI bridge (every PR) — scripts/ws_close_probe.py closes with 1001, with no code, and with no Close at all, and reads back what the application heard, on the pump and inverted alike. It was 1006 for all three, where uvicorn passes the client's. A Close, or a last message, sent in the same instant as the hang-up reaches the application too: the loop used to close a socket at EOF before reading what it had buffered, and a raw client that closed that way was heard as 1006 and lost its last message. Under M0_INVERTED=1 the direct disconnect call drains the submit channel first, or it overtook that message |
| L29 | Nothing follows this side's Close: a socket lingering for the peer's reply is skipped by the heartbeat, so the reply is met by a FIN and never by a ping (RFC 6455 §1.4, §5.5.1) | verified | Conformance test the ASGI bridge (every PR) — ws_probe.py holds its Close reply for a second, three heartbeat periods inside the two-second linger, and requires silence and then a FIN; on the server before the fix three pings arrive in that second. Found by stress-asgi (pre-release): the 300 ms heartbeat landed inside the window between the Close going out and the reply being read, which CPU hogs widen, in 3 rounds of 30, and the close-order phase read 0x89 0x02 "hb" where it expected the FIN -- reported, until the probe learned to name a frame, as the RST it was not |
| L30 | An application may install asyncio's eager task factory: a response it sends in a task's first step, before any await, is answered | verified | Conformance test the ASGI bridge (every PR) — M0_ASGI_EAGER_TASKS=1 makes the bare app install asyncio.eager_task_factory at lifespan startup, and a plain answer and a stream begun in the first step must both arrive; the same task runs in the inverted step. The first step runs inside the executor's spawn, and the job that spawned it held the executor's state as a copy and stored it back as it returned, erasing the completion the step had parked: every such request went unanswered, on the pump and inverted alike. poe test-shim holds the stream such a step begins to its own task, which its client's departure ends, on a slot recycled from a stream still ending (test_an_eager_stream_does_not_take_the_previous_connections_task, B29) |
| L31 | No function in m0serve takes the asyncio executor's state by value, nor holds it inside an argument that is: Mojo 1.1.0 copies a mut argument of 256 bytes or less in and stores the copy back, which erased the drain L8's SIGTERM had begun and the completion L30's first step had parked, so ExecutorState travels by address |
verified | Run the shim tests and the compile gates (every PR) — poe check-copyback reads m0serve's LLVM IR, compiled as build-serve compiles the binary, and matches the type's layout, read from its own constructor, against every argument passed by value, so a raising function whose parameter types the census cannot name is still caught. On every run it first requires a control that takes the type in B20's two shapes and inside a copied struct to be seen, and stops with 2 when it is not; a selftest over canned IR holds the judgement. Putting mut st back in _flush_inverted or dispatch_job fails it, naming the function. The rule, and the sweep that found no other type needing it: mut-arguments-and-raw-addresses |
| L32 | Under the loop inversion a pass never runs inside another: every event a pass reads beside a request whose first step fills the chunk channel is acted on | verified | Smoke test the ASGI executor under the loop inversion (every PR) — scripts/nested_pass_probe.py, against the bare app with its eager task factory installed: a blocker holds the thread while a request whose first step sends 32,768 one-byte frames arrives, and then a second event, so one pass reads both, the stream's first. The second event, a new connection and then a keep-alive request, must be answered. The executor made room in the full channel by running a pass inside that pass, and the wait there overwrote the events the outer pass had not reached: the new connection was accepted only when another arrived, and on epoll the keep-alive request was never read. With the fix reverted the probe fails every run on both platforms (the read case on Linux only: kqueue reports a readable socket again). A pass asked for inside another is refused and named in the log, which the phase also reads; nothing in the tree asks for one now, so no gate fails without the refusal |
| L33 | A lifespan startup that fails ends the server with exit 1 before its ready banner, whichever thread runs it, and on the executor a stop while it runs ends it | verified | Conformance test the ASGI bridge (every PR) — M0_ASGI_STARTUP_FAIL under the executor, a pool and a mount must exit 1 carrying the application's message with no banner printed, and under the loop inversion too (Smoke test the ASGI executor under the loop inversion (every PR)); M0_ASGI_STARTUP_DELAY_S stopped by SIGTERM and by SIGINT must exit 0, the health path unanswered meanwhile. Not under the inversion, whose lifespan runs on the loop's own thread: a stop there waits for the startup to finish. The executor runs the lifespan on its own thread, and the server printed its banner without waiting for it: a failed startup left it accepting with nothing to answer, requests hung, the health path said 200, and SIGTERM exited 0 |
M. Deployment and operations
| id | capability | status | evidence |
|---|---|---|---|
| M1 | Several applications in one process, routed by prefix | verified | Serve two mounted applications from one process (every PR) — --mount |
| M2 | Flags over environment over defaults | verified | Smoke test the serve CLI (every PR) — --host, --port, --workers, --threads, --blocking-threads. A port outside 1-65535, from --port or the environment, is refused by m0serve's first check (m0_wsgi.checks, port) with 78 before the bind, where M0_PORT=0 served on a port the kernel chose and printed :0 and --port 0 was a usage error (review record LF56): test_cli.mojo:test_a_port_out_of_range_is_refused_whichever_way_it_came, and Smoke test --doctor against the server's own exit codes runs both, each both ways |
| M3 | Application discovery from a bare module name | verified | Conformance test the ASGI bridge (every PR) |
| M4 | --app-dir prepended to sys.path, shadowing an installed package |
verified | Smoke test the serve CLI (every PR) — --app-dir |
| M5 | Usage errors exit 2, startup errors exit 1, refusals exit 78 | verified | Smoke test --doctor against the server's own exit codes (every PR) — a listen address the listener would refuse, such as a % (an IPv6 zone) in --host, is a 78 before the bind, both ways, where the server failed at the bind with 1 and the doctor said 0 (review record LF48; test_cli.mojo:test_a_listen_address_that_does_not_parse_is_refused) |
| M6 | --help names every documented flag; --version matches the release |
verified | Smoke test the serve CLI (every PR) — --help, --version |
| M7 | Installable wheel with no toolchain and no dependencies | verified | Build and smoke test the installable wheel (every PR) |
| M8 | The aarch64 wheel built and served on arm64 hardware | verified | Build and smoke test the aarch64 wheel (every PR) |
| M9 | libm0core, the C-ABI shared library the m0serve wheel carries for m0pub, loads through ctypes and answers its one export, m0_shared_fetch_add. Not published on its own: DECISIONS D56 |
verified | Smoke test the C-ABI shared library (every PR) |
| M10 | The documented quickstart is executed, not asserted | verified | Execute the quickstart (every PR) |
| M11 | Correct signal handling as PID 1 in a container | verified | Smoke test SIGTERM delivered to PID 1 in a container (every PR) — the wheel exec'd as PID 1 in python:3.12-slim (checked via /proc/1/cmdline, not trusted); docker stop must be the drain's exit 0 well inside the grace, never SIGKILL at the deadline, alone and as a supervisor reaping two workers whose exits must be clean rather than by the propagated signal; each shape is stopped only once every worker has printed armed for a graceful stop, because one worker answering says nothing of a sibling still importing the application (review AR) |
| M30 | The m0serve command a wheel installs names its own interpreter's shared libpython wherever that is — sysconfig's LIBDIR and LIBPL, and sys.base_prefix's lib/ for a relocated build whose LIBDIR is the build machine's — a search holding every path the Mojo runtime's own lookup tries, and never a static archive; where there is none it exits 78 naming MOJO_PYTHON_LIBRARY before the binary runs, where the runtime used to abort at its first Python call with a stack dump, the doctor included (#612). The flags answered without an interpreter, and a user's own MOJO_PYTHON_LIBRARY or MOJO_PYTHON, are left to the binary |
verified | Run the shim tests and the compile gates (every PR) — poe test-wheel-shim drives the shim's search over sysconfig layouts (python-build-standalone's /install/lib, a python.org framework build, which reports Py_ENABLE_SHARED 0, free-threaded builds, a build with only an archive) and its main with a recorded execve, checks the interpreter running it resolves, and its sabotage arm reverts each rule in memory, each caught by the test written for it |
| M12 | Configuration from a TOML file | out of scope | flags and M0_* environment variables cover it; a third source is a third precedence rule |
| M13 | systemd socket activation (LISTEN_FDS) |
out of scope | no request for it. The old reason said SO_REUSEPORT covered the restart case, which is not true for anyone running m0serve: no flag or variable enables it (D6). What a restart here does get is the supervisor's graceful drain, so in-flight work finishes; a listener that outlives the process is a different property and nothing has asked for it |
| M14 | An HTTP client in Mojo, for server-to-server calls | out of scope | removed 2026-09-29: no application called it, it spoke no TLS, and a blocking call made from a view on the loop stalls every connection that loop holds. A client comes back as a design of its own that answers TLS, where the call runs (a pool thread or the loop) and connection reuse — DECISIONS D55 |
| M15 | Windows, musl | out of scope | no Mojo toolchain for either — see the platform table in README.md |
| M16 | No second process: the running server is one process tree of m0serve binaries, and the wheel requires nothing | verified | Execute the quickstart (every PR) — under --workers 2, pgrep -x m0serve counts exactly a supervisor and two workers (the console script execves, so no Python parent survives), and pip show m0serve lists an empty Requires:. The exact count is the self-test: a check that only looks for a broker it does not expect passes on an empty machine |
| M17 | The live demo: the quickstart's shape as a public page with per-visitor channels and rate and size limits, served from its deploy image | verified | Smoke test the live demo's deploy image (every PR) — deploy/demo/Dockerfile built from the tree's own wheel and probed through a published port by scripts/demo_probe.py: a first visitor is handed a token cookie, HttpOnly and Secure exactly over HTTPS, the page names the served version, and its CSP names the page's one inline script and stylesheet by sha256 with no 'unsafe-inline'; without the cookie the hold views answer 403 and a foreign-Origin upgrade is refused; one publish reaches a second stream on the visitor's channel and a stranger's stream hears nothing for a measured silence; a WebSocket frame comes back to the socket and to the streams, and the page's text-and-tab envelope comes back with its tab id naming the worker the 101's X-Worker names, which is how a tab learns its own socket's worker; a message over the cap is 413 and a burst meets 429 with Retry-After after the first LIMIT attempts and within workers×LIMIT+1; m0serve is PID 1 by /proc/1/cmdline and docker stop drains to exit 0 with held connections open. The same probe, pointed at a URL instead of the image, verifies the live deploy |
| M18 | A native build flavor, poe build-serve-native: the build host's CPU and OS floor, for a server on a desktop Mac, beside the portable wheel. A local build or a release tarball, never a second wheel, because pip cannot select a microarchitecture |
verified | Smoke test Apple Silicon topology, QoS and the native build flavor (every PR) — the native binary launches, and its build.apple_target names the host's generation where the wheel flavor on the same machine said m1 — or other where the toolchain has no predicate for what the build targeted, which is what a virtualized CPU (GitHub's macOS runners) reads; what the line always catches is the wheel binary standing in for the native one on a host that is not an M1 |
| M19 | A mount answered by a compiled-in Mojo handler, with no Python in its request path: --mount PREFIX=mojo, served by MojoPool threads on the mount's own lane |
verified | Smoke test a Mojo mount beside a Python one (every PR) — both mounts answer and the Mojo one names the pool thread that served it; /nativeapp is not swallowed by /native; a mojo-only mounted server exits 2, because a Python host with no Python to host is asking for nothing; SIGTERM exits within 10 s with a Mojo lane live, asserted as an exit because the failure mode is a hung join rather than an error; and under eight GIL-bound connections scripts/mojo_mount_probe.py requires the Mojo mount's p90 to stay inside 25 ms AND under a quarter of the Python route's median, which a shared execution mode cannot do — the p90 and never the p99, because the probe's samplers are closed loops and a runner's window holds a few hundred Mojo samples, so the p99 is the third-slowest request and a starved runner's stalls own it; pointing the probe's mojo-path option at a Python route is the negative arm and fails on both assertions; and beside an ASGI mount with no topology flags the Mojo mount answers within 5 s, which it never did before M20 |
| M20 | A mount set that some mount could not be served in is refused, never started half-served, and one that can only be served with handler threads gets them. Refusals decidable from the flags exit 2 before the bind and the fork, identically in --doctor: only compiled mounts, a hold mount without --realtime or M0_GRANT_KEY, a compiled mount under --threads, and an explicit --blocking-threads below the compiled mounts of a kind. A WSGI mount that an explicit --blocking-threads leaves with no thread on the offloaded loop exits 78 after detection, which the supervisor does not respawn. Without --blocking-threads, a mount set with no inline shape -- a compiled mount, or an ASGI mount beside a WSGI one -- gets the default pool even under --workers, and every mount at least one thread |
verified | Smoke test --doctor against the server's own exit codes (every PR) — nine mount rows, each required to agree with the doctor AND to exit a named code: five 2s (two under --workers 2, where the old placement crash-looped to exit 1), two 78s, and two prefork mount sets that serve with no pool flag, the Mojo one with the doctor reporting blocking_threads_source: default. Smoke test a Mojo mount beside a Python one (every PR) serves /native from both workers under --workers 2. Before: a Mojo mount with no pool served, misrouted to the root application; one thread for two WSGI mounts or an ASGI mount beside a Mojo one left a mount hanging. The sizing: test_cli.mojo:test_pool_is_default_for_mount_sets_that_cannot_run_inline, test_cli.mojo:test_resolve_blocking_threads_never_deals_fewer_threads_than_mounts |
| M21 | A path no mount claims is answered with the server's own 404 whatever the mounts are, never by an application: decided on the loop against the whole mount table, compiled mounts included, before a lane is chosen, so an ASGI or Mojo mount listed first, a single ASGI mount and a WebSocket upgrade all get the same answer as a WSGI-first server | verified | Serve two mounted applications from one process (every PR) — phase 2b serves three shapes with no root mount (ASGI first, Mojo first, ASGI alone) and requires /zzz and a WebSocket upgrade to it to answer 404 with the server's no application mounted body. Before: the loop sent a miss to lane 0 and only a WSGI pool thread asked the table again, so the ASGI application at /a answered /zzz with its own 200 and an upgrade with 403, a lone ASGI mount the same, and a Mojo mount listed first with its router's own 404 body -- all three fail phase 2b on a build without the check, while phase 2, whose mounts are both WSGI, passed throughout |
| M22 | A Mojo mount's pool threads count on their lane and park on wake channels of their own, as the WSGI pool's do, so the elastic wake rules see a busy lane as busy: the Mojo route does not slow down as its pool grows | verified | Smoke test a Mojo mount beside a Python one (every PR) — under M0_POOL_DEBUG=1 with four handler threads, shutdown must report four threads registered on the Mojo lane as on the WSGI one; before, the Mojo lane reported 0. Measured on /native/probe at 16 connections beside a WSGI mount (Apple M4): eight threads went from 202k lane wakes and 104k rps on 2.04 cores to 61k wakes and 133k rps on 1.35 cores, the one-thread figures, and the route now beats the WSGI route beside it per core |
| M23 | Mounts under --threads on free-threaded CPython: WSGI mounts serve from their own lanes in every loop with their prefixes intact, a slow mount does not stall another, and SIGTERM ends every loop; an ASGI mount is refused with 78 on this toolchain (modular/modular#5726) rather than served |
verified | py-canary (weekly) — phase G runs poe smoke-mounts-threads on 3.14t: Django and Flask mounted under --threads 2 --blocking-threads 4, the Flask mount's SCRIPT_NAME and url_for, scripts/hybrid_isolation.py measuring the Flask mount while four blocking Django views hold the Django lane (50 ms budget, a shared lane lands near 2 s), two clean loop exits on SIGTERM, and the ASGI refusal with its message and --doctor's 78. On a GIL-enabled interpreter the same task checks only that a mounted --threads server is refused. It replaces smoke-hybrid's phase 3t, which served ASGI mounts under --threads and so could not pass on the only interpreter where it ran, and which no scheduled job ran |
| M24 | A mounted server under --threads on a GIL-enabled interpreter is refused with 78, never served, as an unmounted one is |
verified | Smoke test mounts under the threaded mode's guard (every PR) — Django and Flask mounted under --threads 2 must exit 78 on CI's GIL-enabled 3.13; the same task is M23's full check on 3.14t |
| M25 | A Mojo or hold mount's lane is not held to one thread by the GIL lanes' progress rule: its threads never attach, so the loop wakes a parked sibling once the head of its ring has waited POOL_FREE_WAKE_AGE_NS since its push, whether or not the ring is moving, while submit keeps the elastic rule (M0_POOL_PARALLEL=0 restores the GIL rule on every lane) |
verified | test_offload.mojo:test_a_gil_free_lane_wakes_a_sibling_behind_a_draining_ring (every PR) — a GIL lane and a GIL-free lane in one pool with the same history (a busy thread, a parked one, a ring that moved): only the GIL-free lane's sibling is woken, at 50 ms on a clock whose GIL threshold is 100 ms, and neither push wakes anyone; reverting the count from the push, the lower threshold, the elastic submit, the knob or the mark each fails it. smoke-mojo-mount's pool-debug phase asserts the wiring: the Mojo lane prints as GIL-free and the WSGI lane does not. Measured on /native/search beside a WSGI mount, four threads, eight connections (Apple M4): 15k rps on 1.2 cores at full selectivity and 36k on 1.4 at a quarter, where the eager wakes served 42k on 4.2 and 78k on 3.5 but cost the trivial /native/probe 21 %; with this rule 43k and 76k, and the price is paid by the lightest routes: the probe 195k on 1.57 cores against 199k on 1.51, the sel=5 search 111k against 115k |
| M26 | A Mojo application ships as an image with no interpreter and no toolchain in it: the pinned toolchain compiles it in a builder stage, the runtime stage carries the binary and the Mojo runtime libraries beside it, and the binary is PID 1 under docker stop |
verified | Smoke test the Mojo demo's deploy image (every PR) — deploy/mojo/Dockerfile, one file for every Mojo app with APP naming its directory, built for apps/blobs from the tree and probed through a published port by scripts/mojo_image_probe.py: /proc/1/cmdline read INSIDE the container is exactly /app/server, no python3, python, python3.x or libpython exists anywhere in the image, and docker stop is the drain's exit 0 inside 10 s. The target CPU follows the docker daemon's architecture, so on CI's runner this is an x86-64 build (x86-64-v2), the only one CI makes of a Mojo app. sabotage-mojo-image (pre-release) builds a shell at PID 1, a stop signal the server ignores, an interpreter added after the image measured itself and one before (which the build refuses), and the unrelocated binary shipped (which the build refuses), and each fails where it should. Measured on linux/arm64 (colima, Apple M4): the blobs image is 29.3 MB compressed and 102.1 MB unpacked, 3.0 MB of it the app, against 60.3 MB and 184.6 MB for the Django demo's image from the 1.4.0 wheel; on CI's x86-64 runner the blobs image is 80.1 MB unpacked. Until 2026-09-18 this row set the hello image's COMPRESSED size (29.2 MB) against the Django demo container's MEMORY (74 MiB, deploy/demo/README.md), which are not the same measure; deploy/mojo/README.md records each figure with its architecture and what it counts. Pre-release until the same day, on a build cost that had not been measured |
| M27 | The Mojo demo served from its deploy image: the producer thread steps and streams inside the image, the page's claims about the image are measurements the image made of itself, and docker stop ends a held stream from the server with exit 0 |
verified | Smoke test the Mojo demo's deploy image (every PR) — the image's last layer measures itself (unpacked bytes of the filesystem and of /app, and that no interpreter is anywhere in it, failing the build if one is) into /app/about.json, which the app reads through M0_IMAGE_FACTS; the probe re-measures with du as root and requires agreement within 1 %, the version to be pyproject.toml's, the target CPU the one asked for, /about to be the file verbatim, and the page's footer to carry exactly its version, its no-Python clause and both sizes as the app formats them. scripts/blobs_probe.py serve, smoke-blobs' main run, then passes through the published port, 100 streams are held and answered, and a stream that has received a frame is ended BY THE SERVER on docker stop -- exit 0 within 10 s, nothing abandoned in the log. sabotage-mojo-image (pre-release) builds a size written rather than measured, a footer sizing the app as the image, a page rendered without its footer, an /about that is not the file, and a producer never told to stop, and each fails in the phase that names it. The same probe, pointed at a URL instead of an image, is what the deploy-blobs job runs against blobs.m0serve.dev after a release: /about naming the version, the footer, whole-state frames and a producer stepping |
| M28 | A mount set larger than a loop's handler pool can lane -- 126 applications, the lanes its wake block has room for -- is refused with 78 before the bind and the fork, and --doctor refuses it with the same code: every mount is a submit lane whenever the loop hands requests to threads, and one ASGI mount among them puts the set on the pool, so the cap holds whatever --blocking-threads says |
verified | Smoke test --doctor against the server's own exit codes (every PR) — 127 mounts under the zero-config pool and under --blocking-threads 0, each required to agree with the doctor and to exit 78, and 128 with a Mojo mount given no thread, where the mount refusal (2) must come first. Before: the zero-config server bound, imported every mount and exited 1 with lane 127 has no wake words from add_lane, while the doctor reported 0. The unit half is test_checks.mojo:test_more_mounts_than_a_pool_has_lanes_are_refused_with_78 |
| M29 | IPv6: --host :: (and M0_HOST=::, and a Server.listen_and_serve on [::]:PORT) listens on IPv6 and IPv4, with IPV6_V6ONLY set to 0 rather than left to a system default that differs by host; --host ::1 listens on the IPv6 loopback alone; [::1], as a URL writes it, is accepted wherever a host is read; 0.0.0.0, 127.0.0.1 and localhost listen as they always did. A client is reported in its own family -- REMOTE_ADDR, ASGI's scope["client"], the access log's remote_addr -- and an IPv4 client of :: as IPv4 (127.0.0.1, where gunicorn and uvicorn report ::ffff:127.0.0.1), so moving a server from 0.0.0.0 to :: changes no address an application sees |
verified | Smoke test IPv6 listeners (every PR) — m0serve on :: (WSGI and ASGI, a query string answered, --workers 2 with connections handed between workers, --workers 2 --spawn-workers, --reload, the doctor), on [::1] refusing IPv4 and on 127.0.0.1 refusing IPv6, and the Mojo host on M0_HOST=:: and --host ::1 and its doctor's [::]:PORT; its first phase fails a runner with no IPv6 loopback. Before: AF_INET6 was OpenBSD's 24, which macOS refuses and Linux reads as AF_PPPOX, inet_pton kept four of an IPv6 address's sixteen bytes, and every listener was IPv4. The unit half is test_ipv6.mojo:test_dual_stack_is_set_not_inherited |
N. Writing an application in Mojo
Sections A through M are about serving Python. These rows are about the
framework layer an application written in Mojo builds on — m0-http's
router, response constructors and HTML helpers — and each is gated by an
example under apps/ driven on the wire, so a row here says the layer
answers correctly, not that it is pleasant.
| id | capability | status | evidence |
|---|---|---|---|
| N1 | A server-rendered fragment app: one URL answers a bare fragment under HX-Request: true and a whole document without it, Vary: HX-Request on both; a urlencoded form round-trips a multi-byte title and keeps every value of a repeated checkbox key; a markup title renders as text; a body that is not a form is refused; 405 with a correct Allow |
verified | Smoke test the fragment notes app (every PR) — apps/fragment_notes, the same resource apps/notes_api serves as JSON |
| N2 | A view is a function the URL table names: registration assigns the handler id, a path in a method it does not take is 405 with Allow, an unmatched path is 404, and there is no fallthrough for a route to reach the wrong view. A view registered as reading receives the state borrowed, so a write in it does not compile (poe sabotage-views, inside test-all, compiles the counter-examples and insists they are refused) |
verified | test_views.mojo:test_wrong_method_is_405_with_allow (every PR) |
| N3 | A fragment names itself: Fragment writes its root id once and swap generates the attribute that targets it from that same id; Html.attr owns the attribute delimiters and escapes the value, text escapes, raw says so by name |
verified | test_html.mojo:test_fragment_emits_its_id_once_and_targets_it (every PR) |
| N4 | Vary accumulates: a response that varies on two request headers names both, and a name is never repeated |
verified | test_reply.mojo:test_vary_appends_rather_than_overwrites (every PR) — the overwrite it replaced kept whichever header was set last, unnoticed while nothing set Vary twice |
| N5 | Reverse URLs from the route pattern: url_for(PATTERN, params...) fills a pattern's :name segments in order, percent-encodes each value so it stays one segment, and raises on an arity mismatch, and on an empty value or a dot segment (., .., which a browser resolves before it sends the request), either of which would reverse to another route; every route a router registers reverses to a path that matches back to the same handler |
verified | test_router.mojo:test_every_registered_route_reverses_and_matches (every PR) — the pattern is a comptime constant given to both add and url_for, so a misspelled route is a compile error rather than a dead link; the refusals are test_router.mojo:test_url_for_refuses_an_empty_value and test_url_for_refuses_a_dot_segment |
| N6 | Urlencoded form bodies: form(req) decodes application/x-www-form-urlencoded into an ordered multimap that keeps every value of a repeated key, and is empty for any other content type so a JSON body is never read as a form; it decodes every value exactly as the query-string parser does, proven by one table run through both |
verified | test_form.mojo:test_decodes_exactly_as_the_query_parser_does (every PR) — kept as a separate loop from URI.parse on purpose: that one is a last-wins dictionary by contract, and sharing the loop would force one of the two to change |
| N7 | One renderer, two transports: Fragment[V] takes the frontend vocabulary as a type parameter, Htmx and Datastar — the two the layer ships; N21 is an application's own — each spelling their attributes in one struct, so the same renderer serves both; a Fragment[Datastar] is one line and goes out verbatim as the single elements line of a patch_elements frame, needing no selector because it carries its own id; and a request carrying Datastar-Request: true is answered the bare fragment as text/html, which Datastar morphs by that id |
verified | test_fragment_frame.mojo:test_a_fragment_goes_out_verbatim_as_one_elements_line (every PR) — apps/datastar_todo's list is that renderer, and smoke-todo greps its output out of a live stream's frame; the header arm is test_fragment.mojo:test_a_datastar_action_gets_the_bare_fragment plus a Datastar-Request request in smoke-fragment-notes |
| N8 | A history restore is a page, and so is a boosted navigation, for a client that does not say which it wants (htmx 2; N22 is the client that does): HX-History-Restore-Request: true or HX-Boosted: true beside HX-Request: true answers the whole document, because htmx swaps the answer's body into the page it is rebuilding (or into the body it targets) and a bare fragment there is a page with no head, or a body that is one section; every answer's Vary names all five headers the decision reads |
verified | Smoke test the fragment notes app (every PR) — test_fragment.mojo:test_a_history_restore_is_a_page is the unit form; the request shape is htmx 2.0.4's own loadHistoryFromServer, which sets both headers |
| N9 | A table knows where it is mounted: Views[S](Mount(prefix)) registers every route under the prefix, Mount.url_for reverses to those same paths, and PoolContext.prefix carries the prefix from the pool's own lane table, so a handler on a mounted lane matches exactly what the loop sends it and renders links that carry the prefix. Gated the way smoke-hybrid gates Python's reverse(): a link the mount rendered is followed, and the same link without its prefix is followed too and answered 404 by the root application |
verified | Smoke test a Mojo mount beside a Python one (every PR) — the unit forms are test_router.mojo:test_a_mount_prefixes_every_reversed_url and test_views.mojo:test_a_mounted_table_matches_under_its_prefix_only; the mount is m0serve's own MojoMount, now a Views table whose index renders its routes |
| N10 | Elements as expressions: el(tag, attrs, children...), void, attr, flag and text compose as strings with the builder's escaping — the same list from both tiers is byte-identical — and Fragment[V].el(tag, verb, url, ...) spells the swap for the vocabulary from the tag it is given once, so a renderer reads as its markup with each escaping context named at the hole; apps/fragment_notes's list is written this way beside its detail view in the builder |
verified | test_html.mojo:test_elements_compose_as_expressions_with_escaping_named (every PR) — the swapping form is test_html.mojo:test_fragment_el_spells_the_swap_from_the_tag_it_is_given; smoke-fragment-notes did not change when the list moved onto the tier |
| N11 | An SSE hold from a Mojo mount: a view on a mounted Views table returns the two instruction headers a Django view returns (M0-Hold: stream, M0-Channel), the pool thread rewrites the response into the stream's head and sends the loop the same h frame a WSGI pool thread sends before completing, the loop subscribes the slot in the registries it drains, and a publish from Python reaches the Mojo-held stream with its id — the client's Last-Event-ID honoured, comment heartbeats kept, no chunked framing, the slot released when the client leaves, and the head answered while every Python thread is busy; a streaming response that is not a hold is still refused 409, and without --realtime the head is served as an ordinary response, as a Django hold view's is |
verified | Smoke test an SSE hold from a Mojo mount (every PR) — the unit form is test_mojo_pool.mojo:test_a_hold_sends_the_loop_its_frame_and_completes_with_the_head, and poe sabotage-pool reverts both the frame's send and the refusal; the mechanism is hold-from-a-mojo-mount |
| N12 | A Datastar form end to end: Fragment[Datastar] on a <form> emits the submit action with {contentType: 'form'}, form(req) reads what the browser sends, and the broadcast morphs the answer into every tab; gated by a smoke that posts the form and greps the frame, and a browser run that submits it for real, since the form arm is where a wrong spelling fails silently |
verified | Smoke test the Datastar todo demo (every PR) — posts the rename form urlencoded and greps the frame, greps the page for the form arm's wire spelling, and refuses a JSON body posted to the same route; the arm in a real browser is browser-datastar-form (pre-release): Chromium presses Enter in the rename form and the pinned bundle posts the fields urlencoded, a bound field's action posts the signal store as JSON (D21), and a second tab morphs |
| N13 | Sessions and CSRF behind a login: a stateless signed cookie v1.<kid>.<exp>.<subject>.<tag> — HMAC-SHA256 over the rest, the tag checked in constant time, the expiry against the host clock, a retired key ending every session under it — emitted through ResponseCookieJar.add_raw with HttpOnly; SameSite=Lax; Path=/; a CSRF token derived from that tag, carried as a hidden field by a POST and as an X-CSRF-Token header by a DELETE (N22), read from the body or that header and never from the query string, a header that is present deciding; a guard that is an early return, answering 303 to a navigation and 401 carrying the login fragment to a swap; and a server that refuses to start (78) without its key or its password |
verified | Smoke test the fragment notes app (every PR) — every arm on the wire, with the forgeries signed by scripts/notes_session.py so the verifier is never checked against itself; the unit form is test_session.mojo:test_a_cookie_the_issuer_signed_names_its_subject against the same issuer's vectors, and poe sabotage-notes-login (pre-release) reverts the tag check, the expiry, the token, the guard and the cookie attributes one at a time. The arm curl cannot judge is browser-notes-login (pre-release): Chromium signs in, writes and deletes against htmx 4.0.0, the POST carrying its token in the body and the DELETE in a header with no csrf in its URL. The design is a-login-on-the-notes-app |
| N14 | A Mojo mount from somewhere else: an application supplies its own MojoMount as a module named m0serve_mount in a directory of its own, and poe build-serve with M0SERVE_MOUNT_DIR naming that directory builds the same m0serve.mojo into a binary that serves the application's routes — its path parameters, its per-thread state, its prefix-aware links — beside a Python mount, with the demo mount's routes gone rather than answering too; the directory REPLACES the default on the include path, because with both present the first root wins without a word, and a directory with no mount module is refused by the build before anything compiles |
verified | Smoke test a Mojo mount supplied by module (every PR) — sabotaged by keeping the default mount directory on the path beside the fixture's, which built cleanly and served the demo, and failed the smoke's first assertion; that the conformance survives leaving the entry file is measured by Smoke test a Mojo mount beside a Python one passing with the demo mount in the module; the design is a-mount-from-somewhere-else |
| N15 | Periodic work off the event loop: an application with a simulation cadence runs it on a thread of its own and publishes each step through the BroadcastBus with skip_worker = -1, the loop draining it into sse_peer_frame and paying only that; the bus is joined at ONE worker too, because here it is the thread-to-loop channel rather than only worker-to-worker, and publishing with nothing draining fails quietly; step ids arrive contiguous, so a cadence the best-effort bus cannot carry is a failure rather than a silence; a trivial request is unaffected while a 200ms step runs off the loop and waits out that step when the identical work is moved onto tick (M0_SIM_ON_LOOP=1, the negative arm, which also asserts the step ran there at all); a step still running at SIGTERM is joined within the drain rather than hanging it; and under M0_WORKERS=2 streams held by EVERY worker receive the steps, which needs the thread to publish to every worker's channel (the app shipped handing it bus.write_fds[0] alone) and the workers to share accepts so the held streams span both, each naming its worker in x-worker |
verified | Smoke test periodic work off the event loop (every PR) — apps/sim_loop, whose two arms are the same step function reached two ways, and which runs on the Mojo host (E21), so the thread, its publish and its join are the host's Producer now and sabotage-host breaks those rules against smoke-host. Before the host, this gate was sabotaged by publishing with skip_worker = 0 instead of -1, which delivers to nobody and fails the first assertion; by handing the thread bus.write_fds[0] alone, which fails the two-worker phase's frame count; and, over that broken fan-out, by M0_ACCEPT_SHARE=0: the bare race put all four streams on one worker in 1 of 3 macOS runs, where the frame count passes and only the phase's worker count fails it. The cost the row exists to avoid is the duty cycle: retention tracks 1 - duty and the work transfers into p99 about one for one, which is what HTTPService.tick's docstring now says |
| N16 | A live world the page cannot hold: apps/blobs, a Views table that also owns the four SSE hooks, whose producer thread steps up to sixteen metaballs at 10 Hz — the field sampled on a 192 × 192 grid, its outlines traced by marching squares chained on grid-edge identity, closed by a zero border, holes dropped, each resampled to 48 vertices and kept in the slot its predecessor held — and publishes each step as one full-state patch-signals frame of underscored signals — every slot, a polygon() of exactly 48 vertices inside the stage, one winding, in the vertex order nearest the slot's previous polygon (a slot's first starting topmost), a new shape first drawn as its parent's outline or a seed of itself and an ended one shrinking away before its slot hides — to every worker's bus channel; the stream sends the newest state at open (I24); the producer pauses with nobody watching and slows to 2 Hz after an idle stretch; a click is validated and capped on the loop (400 for a body without numeric x and y, 429 past four a second per connection), clamped so a lone blob stays clear of the walls, handed to the producer over the pre-fork shared page, and reaches every stream; seventeen blobs evict to sixteen; the kernel leaves no contour open; a drain with a stream held exits 0 without abandoning the producer; and it runs on the Mojo host (E21), so under M0_WORKERS=2 streams on both workers receive every step, a click posted to worker 1 reaches the producer in worker 0 over the shared page, and the pause waits for the viewers summed across workers |
verified | Smoke test the blobs demo (every PR) — three servers from one binary, each on its own port, the last with two workers, where the streams must span both workers or the phase fails as vacuous; also times a trivial route while steps run and records the step time, the most shapes drawn, the largest frame and the bytes per viewer per second. Its probe also checks consecutive frames: no slot twists (its vertices move no more than the nearest rotation's, to the wire's 0.1 % rounding), none hides without having shrunk, and none appears but as a copy of a shape in the frame before or a seed under 0.3 of what it grows into. poe test-apps (inside test-all) holds the kernel's contract, every trace checked against the one before, and its rules — the prototype's own layout traces to 1190 segments and four cycles, one a hole it drew and the kernel drops; sixteen blobs pressed against a wall still close; a moving shape keeps its slot and a new one never inherits a slot that just emptied; a peanut whose highest point passes between its lobes keeps its vertex order (topmost every step dragged every vertex around the outline: 64 of 1,758 slot steps on 1.5.0's live stream); a merge becomes the merged outline then shrinks, a split starts as its parent, an ended shape shrinks and a drop grows from a seed — plus the frame and the drop box. The browser half is browser-blobs (pre-release): Chromium requires a patched _-signal to drive data-style:clip-path, a click to post exactly x and y at the point clicked with a second tab gaining the blob, and both tabs to reopen the stream and draw the new world after the server restarts, which Datastar's default retry does not do after a clean close. sabotage-blobs (pre-release) breaks thirty-one rules one at a time — seventeen against the smoke (among them a twist and both kinds of pop reaching the wire, the pause counting worker 0's viewers alone, and worker 1 holding a board of its own, whose click never crosses), fourteen kernel rules against the unit tests (complementary cases wound alike, chaining on rounded coordinates, no border, holes kept, vertex 0 not re-chosen or rotated past the topmost, no slot matching, a just-emptied slot reused, a slot restarting at its topmost vertex every step, a shape hidden at once, a new shape drawn whole, a split drawn whole beside its parent, an absorbed shape skipping the merged outline, an unclamped drop) — and requires the gate to fail for each |
| N17 | One list, two workers: apps/datastar_todo on the Mojo host serves M0_WORKERS=2 over one SQLite file, each worker restoring the same journal and joining its stream to the bus, so a mutation on either worker reaches every tab; and a mutation holds SQLite's write lock from its change until its frame is published (BEGIN IMMEDIATE ... COMMIT), so frames are numbered in the order their renders saw the data and the newest frame a tab keeps is never missing a change an older one had |
verified | Smoke test the Datastar todo demo (every PR) — scripts/todo_probe.py two: streams that must span both workers (or the phase is vacuous), twenty-five adds at once from a connection held by each worker, every stream converging on all fifty, the page agreeing, and the persisted broadcast log read from the database file, each frame required to hold every todo of the frame before it. The race lasts microseconds, so the app's M0_TODO_RENDER_PAUSE_MS (5 ms in the gate) widens it: a build without the lock failed that check 5 of 5 rounds, the build with it passed 5 of 5; without the pause the unlocked build passed 10 of 10, which is why the gate uses it |
| N18 | A Views table and its state are a host application with no handler of their own: ViewsApp[S] serves a state conforming to ViewState (make, urls), forwarding func to dispatch and before_request to answer_on_loop; and a state that lives in one process declares max_workers, so M0_WORKERS above it is refused with 78, naming the variable, before anything is bound, instead of serving workers that each hold a different copy |
verified | Smoke test the fragment notes app (every PR) — apps/fragment_notes is serve[ViewsApp[NoteStore]], its whole wire contract unchanged, and M0_WORKERS=2 must exit 78 naming the variable without the listening banner (the app previously ignored the variable and served one process); the adapter's forwarding is test_host.mojo:test_a_views_app_answers_for_its_state |
| N19 | A route's placement is a property of the route: add_read and add_write take on_loop=True, which keeps the route in the table (one Allow, one url_for, one 404) and has answer_on_loop(req, state) — what ViewsApp.before_request and ViewService.before_request call — answer it ON THE LOOP with the loop instance's state, reads borrowed and writes mut, before the request becomes a pool job; a view that raises there is 500, since the hook cannot raise; the stateless answer_on_loop(req) declines it and dispatch answers it one round trip later, which is what m0serve's loop does, its handler holding no Mojo table (D32); add_loop stays the stateless form both hosts answer on the loop |
verified | test_views.mojo:test_an_on_loop_route_is_answered_with_its_state_before_dispatch (every PR) — its neighbours cover the stateless decline, the 500 and ViewService; sabotage-host --only views (pre-release) answers the on-loop read without its state and insists the test fails. Where a stream open goes under a pool, and why: the-ramp-test |
| N20 | One views module, two hosts: apps/ramp/views.mojo — a Views[Ramp] table, its views and its state, built from the prefix alone — compiles into m0serve as MojoMount under --mount /x=mojo (M0SERVE_MOUNT_DIR, plus M0SERVE_INCLUDE for the application's own module root) and into a host binary under the same Mount("/x"), and the two answer a fixed request set under the prefix byte-identically apart from Date (the index, now, the compute route, a 404 inside the prefix, a 405 with Allow, an OPTIONS 204), follow the same rendered link, answer what each host owns outside the prefix (the Python application on m0serve, the table's 404 on the host, and /xapp never swallowed by /x), and keep /x/now answered while a connection holds /x/slow on a lane of two — on m0serve from the lane's other thread (D32), on the host from the loop — where the host with no lane fails the same bound; and with the lane FULL (two connections on two threads) the host's loop route still answers, while m0serve's waits for a thread — D32's divergence, measured on every run rather than argued |
verified | Smoke test one views module on two hosts (every PR) — scripts/ramp_probe.py diffs status, headers and body per request across the two ports (only Date differs, measured) and follows the index's links on each; the placement bound is 100 ms, sim_loop's, and M0_BLOCKING_THREADS=0 on the host is the negative arm that must reach it (1 ms and 0 ms on the two hosts with one loader, 385 ms on the bare loop, on an M4); the full-lane arm bounds the host (0 ms) and records m0serve (196 ms). The design and the measurements: the-ramp-test |
| N21 | A vocabulary an application defines: Vocabulary is open to a conformance written in an application's own source, through the .mojoc, using nothing spelled with an underscore — Html.open_kind() says which element is open, and a defaulted static verbs() names the verbs the library takes; the layer, not the conformance, refuses a verb outside that list before swap runs, so a vocabulary that checks nothing still refuses a typo and one with a sixth verb (htmx 4's query, which is how the built-in Htmx takes it) is allowed it while one left on the default five, Datastar, still is not |
verified | test_html.mojo:test_the_layer_refuses_a_verb_a_conformance_never_checked (every PR) — poe check-app-vocabulary, inside test-all, is the outside half: an htmx 4 vocabulary compiled from a directory that is not under the repository root, asserting hx-swap="outerMorph" and hx-query by exact string in both tiers and open_kind()'s four answers through a second, synthetic vocabulary (htmx picks its own event, so Htmx4 never asks), then read by hxlint, vendored from hx-flask and generated from htmx 4's source tree, which the gate first makes fail three ways; test_html.mojo:test_a_vocabulary_names_its_own_verbs and test_html.mojo:test_a_conformance_reads_the_element_kind_without_an_underscore are the other two arms. The htmx 4 APPLICATION this row once disclaimed — a DELETE's fields ride the query string there, CSRF token included, which no lint sees — is N22 |
| N22 | The built-in Htmx vocabulary and page_or_fragment speak htmx 4: HX-Request-Type DECIDES when it says partial (the bare fragment) or full (the whole document) — a v4 history restore carries full and no HX-Request at all, and a v4 boosted element with its own target says partial beside HX-Boosted: true and gets the fragment, the one place the two majors need different answers to the same older headers — while a request without it is read by N8's rule unchanged and a value that is neither word falls through to it; Vary names it fifth; Htmx takes htmx 4's sixth verb, query, and spells a swap in the same three per-element attributes, which explicit inheritance leaves intact; and a DELETE's CSRF token travels as a header, because htmx 4 sends a DELETE's fields in the query string with no setting to change it, so the notes app's delete form holds no field and carries hx-headers |
verified | Smoke test the fragment notes app (every PR) — the four request shapes on the wire, the shell loading 4.0.0 by exact URL with no htmx-config left behind, the delete form carrying this session's token as hx-headers and no csrf field, and a DELETE refused with its token in the query, absent, another session's, or wrong in the header beside a right field; test_fragment.mojo:test_htmx_4_says_which_it_wants_and_is_taken_at_its_word is the unit form, with the restore, the boosted partial and the fall-through beside it. What the bundle actually SENDS is browser-notes-login (pre-release): Chromium against 4.0.0 records HX-Request-Type: partial on both writes, the token header on the DELETE and nothing in its URL, and a 401 swapped into the list's place, each arm shown to fail first (the header reverted, the field restored, the 2.0.4 bundle); poe sabotage-notes-login (pre-release) reverts both header branches, the fifth Vary name and the token rules. Not claimed: hx-boost or pushed history clicked through in a browser — no app here uses either, so those two shapes are gated as requests, from headers measured in Chromium. The record is the-layer-moves-to-htmx-4 |
| N23 | The m0 wheel carries the framework as SOURCE, mapped from the repository's own manifest: packaging/m0/'s build hook force-includes every file git ls-files names under the five trees an application compiles against (m0-core/src, m0-http/src, m0-http/lightbug_http, m0-http/m0_host, m0-datastar/src) into m0/_mojo/<import name>/, so one include root resolves all five with no .mojoc anywhere and nothing is staged or copied on disk; an untracked file cannot ship and a tracked one cannot be dropped, with no exclude list; the three tools a release build runs (relocate.py, bundle_artifact.py, and the binfmt.py both import) ride unedited; m0/_build_info.json records the release, the tree's commit, and gated_mojo READ from the root pyproject's exact mojo==X — the pin every gate ran on — never written by hand; the wheel is py3-none-any, declares no dependency (D39), and is versioned apart from the repository (D43) |
verified | Smoke test the m0 wheel (every PR) — opens the wheel and requires the names under m0/_mojo/ to EQUAL the mapped manifest, spelled a second time in the gate so the recipe's table and the gate's must agree, every file byte-identical to the tree, the fork's LICENSE present, the tools byte-identical to scripts/, no .mojoc or __pycache__, gated_mojo equal to the root pin, the commit equal to HEAD, and no Requires-Dist; then builds a second wheel with an ignored decoy file planted in m0-http/src and requires it not to ship, because with nothing untracked in the tree a directory walk and the manifest make the same wheel. poe sabotage-m0-wheel (pre-release) drops a tree, walks the directory instead of the manifest, drops binfmt.py, writes the pin by hand, adds the dependency, and ignores the local version label. The design is the-m0-wheel |
| N24 | An application outside this repository builds against the installed wheel: m0 build compiles src/server.mojo with ONE include root, the wheel's m0/_mojo, and RENAMES the result onto bin/server, never writing over a binary that may be running, so a rebuild under a live server leaves it serving and a build that fails leaves bin/ byte-identical; m0 test is mojo run per test/test_*.mojo with the framework's root and src/, serial, output untouched, exit 1 naming the files that failed, and a run that found no test file is refused (78), not passed; a release build compiles for the platform's baseline CPU (build-serve's table; native beside it is exit 2), relocates, and bundles the Mojo runtime into dist/, which then runs with the build venv gone; m0 include prints the root. Exit codes are a closed set — 0, 1 the tool m0 ran failed, 2 the command line, 78 a refusal (D40) |
verified | Smoke test the m0 wheel (every PR) — in a temporary directory laid out as a user's project, .venv beside src/: apps/fragment_notes/server.mojo copied in and built, served, /health and the login page read; one string literal changed and rebuilt WHILE the old process serves, which must survive, still serve the old literal, keep its inode byte-identical (read through a hard link), and be replaced by a binary serving the new one; a syntax error leaves the binary's hash unchanged and no .server.next; a copied apps/blobs test that imports the framework and the application's own modules passes, a failing file beside it is exit 1 and named, no files is the 78 sentence; dist/server searches only @loader_path/$ORIGIN and answers its doctor from another directory with the venv moved away. Build and test seconds are recorded, never gated. Not claimed: that a release build compiled for the baseline rather than the host — the artifact works where it was built, which is where the gate runs — nor that the gate holds the RENAME as a mechanism on macOS, where mojo 1.1.0's link step replaces an existing output itself and a reverted rule passes (measured; the outcome is what is asserted) |
| N25 | The toolchain is a checked PAIR and every refusal is a sentence: checks.py holds ONE ordered list — platform, mojo-installed, mojo-gated, max-gated, c-compiler, patchelf, project — that every mojo-running command reads to its first failure and m0 doctor reads whole but for the release-only patchelf, each failure exit 78 with one m0: detail (fix) line and nothing run. m0 compares the mojo distribution in its OWN environment to gated_mojo by string equality, with no override (D39), and runs <sys.prefix>/bin/mojo, never PATH's; a foreign m0 in a project that has its own .venv is told uv run m0. The C-compiler check is the name cc — the only one mojo 1.1.0 looks for, so gcc without it is refused by name — plus a one-line link with -lm, because a cc that cannot link otherwise fails after the whole compile; macOS asks xcode-select -p first. m0 test skips it: mojo run links nothing. patchelf is asked by m0 build --release alone (not by the doctor, which would fail a machine that never releases), and only on Linux, where the release build rewrites the binary's search path with it after the compile; its absence is the same exit 78, naming apt-get install patchelf, before anything compiles |
verified | Smoke test the m0 wheel (every PR) — with no network: a mojo 9.9.9 distribution zipped on the spot, whose mojo script drops a marker, gives the pair sentence verbatim from build, test and the doctor's mojo-gated check, marker absent; that venv's m0 run from the real project gives the prefix sentence; a venv with no mojo, a directory with no src/server.mojo, PATH stripped to the venv, a gcc with no cc, and a cc that prints cannot find crti.o each give their sentence whole, 78, and an untouched bin/server — while m0 test under the stripped PATH still passes; and EVERY real build and test in the gate ran with the stub mojo first on PATH, the marker absent at the end and the stub then shown runnable, so its absence means something. packaging/m0/tests/test_m0.py, run by the same step under the installed interpreter, holds what no CI leg can be put into: the platform verdicts and the patchelf verdict, which only a release build on Linux asks. poe sabotage-m0-wheel (pre-release) reverts each check with the unit phase OFF, so the arm and not the unit test is what must fail |
| N26 | m0 doctor asks the toolchain's question and then the application's: every check in the list but the release-only patchelf, then bin/server run with its own doctor flag, whatever followed a bare double dash, and the environment untouched; the host's report (E31) is taken from the LAST stdout line and required to open "m0_host":"1" — another format number is refused by name, m0 build the fix. It exits with the first failed check's code, else the application's own, else 0; no build is green, a binary older than src/ is reported stale and never failed, and an application whose own main refuses before serve prints no report, which is recorded with its words. Asked for JSON, one object is the last stdout line: a format number "m0":"1", ok, exit, versions (the release from the wheel's metadata, the gated and installed mojo, the framework tree and commit), paths, checks, and app with the host's report verbatim |
verified | Smoke test the m0 wheel (every PR) — the report's keys in order, green with the host's report and its topology inside and paths.mojo the project venv's; stale true after an edit and the exit still 0, false after a build; a worker count of zero handed through exits 78 with every m0 check passing and the failed check in the HOST's report, so the code came through the binary; fragment_notes run without its key exits 78 with report: null and the app's sentence in output. The unit file holds the format refusal and the exit order, and that a binary killed by a signal is exit 1 naming the signal, never the negative status (which sys.exit would turn into 250 or 245) |
| N27 | m0 new NAME writes an application, from a closed set of four templates: views, a server-rendered list swapped in place by htmx 4; live, a producer pushing full-state Datastar frames to every tab (D44); board, a list a view publishes to every tab (N52); and auth, the views list behind a login (N45, D53). It needs no toolchain and no network — it runs through uvx before anything is installed — and copies files the wheel carries, replacing four tokens by plain string replacement: there is no template engine. NAME, or a path's last segment, is the directory, the project and the deploy's app name at once (lowercase letters, digits and hyphens, a letter first and a letter or digit last, at most 40, and not the name of a package the scaffold pins or its toolchain requires, m0, mojo, max-core or mojo-compiler, which would make the project depend on itself), and a name that cannot be all three, or a target that exists and is not empty, is exit 2 with one sentence and nothing written. m0 new . writes the current directory, which must be empty (a .git in it does not count: git init first is the common way in), and takes its name; the next commands it prints carry no cd, and the serve line binds 127.0.0.1. m0 --help lists the four templates, a line each, read from the one table m0 new --help reads, and an m0 new given no template says it took the default and lists the other three: the agent runs chose from the top-level help, and every one that found no name there wrote its application twice. What is written is a closed manifest — src/ with server.mojo importing its siblings by bare name, test/, a pyproject.toml with no build system that pins BOTH mojo and m0 exactly in its dev group, AGENTS.md with CLAUDE.md pointing at it, an executable smoke.sh, a workflow, and deploy/ — after which the user's own uv run m0 build makes the venv and the binary, uv run m0 test runs the template's tests, and the server answers on the wire |
verified | Smoke test the m0 scaffold (every PR) — per template, outside the tree: m0 --help lists the gate's own spelling of the template set; written by uvx offline with PATH holding uv's directory and the system's alone; the written tree EQUAL to the gate's own second spelling of the manifest, both pins present, no token left, no build system; a second m0 new onto its output and an unusable name each exit 2 with the sentence whole and the tree untouched; m0 new . in an empty directory writes the same manifest named for that directory and prints no cd, and a second m0 new . there is exit 2 with its sentence; then a sync that REFRESHES m0 — the gate's own step, because uv serves a version it has cached and this wheel is rebuilt under one: a reverted layer fix once passed — with every file the wheel carries under m0/ required byte-equal in the venv, and the literal uv run m0 build against the tree's wheel (stamped <version>+tree, which no index can serve, so the exact pin cannot resolve to a published one), m0 test green and then red with a failing test appended, the server started on the run's own port and stopped by pid with exit 0, m0 doctor as JSON green with the host's report inside, and the scaffold's own smoke.sh green on another port. live on the wire, served behind --blocking-threads 2 so that a stream opened off the loop is refused where the gate sees it: the fragment at first paint with the pinned Datastar tag, two DIFFERING datastar-patch-elements frames for the root id inside five seconds, then a kick posted with that stream still open and, inside five seconds, a frame whose lowest bar stands above the highest bar the wave drew before it (N40's board word, which nothing else on the wire reads), the kick counted and no frame refused by the bus. views is served behind --blocking-threads 2 as well: after a create, twelve reads of the list each hold the item, since every view that touches the list is on_loop=True (on the pool each thread built a list of its own). packaging/m0/tests/test_m0.py holds the name rule (the required-package names included), a directory holding only .git/, and the manifest under the installed interpreter. The sync resolves online with every wheel cached, the dependency the job's own sync already has. deploy/ is N31's. poe sabotage-scaffold (pre-release) breaks each rule from the template side, views: the list is read off the loop among them, the wire rules with the template's own tests switched off |
| N28 | The scaffold's templates are real source files that compile UNSUBSTITUTED against the tree: the application's name appears only where a compiler does not look — inside string literals, TOML and Markdown — so a change to the layer that breaks a template fails in the pull request that made it, not in the next person's first build. Every file under templates/ is named by new.py's manifest and every name there is a file; the two version tokens appear in pyproject.toml alone; and no other token of that shape exists, since a misspelled one would be written verbatim |
verified | Run the shim tests and the compile gates (every PR) — poe check-templates, inside test-all: mojo build of each template's src/server.mojo in place with the artifact thrown away, mojo run of each test file, then the manifest compared both ways with the files on disk and every token checked against the closed three |
| N29 | An error a person may see is a fragment with its own status: page_or_fragment(..., status=422) with text left alone answers 422 Unprocessable Content — the status's own RFC 9110 phrase from reply.reason_phrase — where it used to answer 422 OK, the default having been the literal OK; an explicit text still wins. htmx 4 swaps every 4xx, so the scaffold's views template answers a bad form with the list, the message inside it under an alert role, and a missing item with a 404 fragment; reply.problem stays for routes no browser swaps |
verified | Smoke test the m0 scaffold (every PR) — an empty title over a socket is 422 Unprocessable Content, text/html, its body the bare fragment under its root id holding the alert, and a missing item is 404 Not Found and the fragment; test_fragment.mojo:test_a_status_alone_carries_its_own_reason_phrase holds the phrase, the default and the override |
| N30 | m0 dev is build-then-swap: it builds and serves bin/server with whatever followed a bare double dash, then polls src/ and pyproject.toml by mtime with the standard library alone (D40 — no watcher dependency). On a change it builds WHILE the old server serves — a build is about ten seconds, too long to be down, and m0 build renames into place without touching the running inode (N24) — and a build that FAILS changes nothing: the old process keeps serving the last good build and m0 dev keeps watching. Only a build that succeeded ends the old process: SIGTERM by pid, a wait for that pid to exit (6 s, the host's drain plus one; SIGKILL after, named on stderr), how it ended said (pid N exited 0), and only then the new binary started, so there is never a second process on the port. Preflight is checks.py's one list (N25), asked again before every rebuild: a refusal (the toolchain moved under an edited pyproject.toml) builds nothing, prints the sentence and leaves the old server serving. Ctrl-C, or SIGTERM, stops the server by pid and exits 0; a server that exits on its own is reported and the next edit starts it again |
verified | Smoke test m0 dev on the scaffold (every PR) — a scaffolded views app outside the tree, the literal uv run m0 dev with a port handed through: the pid it names is the ONE pid listening, asked of the kernel (lsof, else ss) and not of m0 dev; one string literal edited and the port polled from the save — the OLD literal answered at least three times after it and before the new one is ever seen, then the old pid gone, reported exited 0, and a different single pid listening; a syntax error saved into the ENTRY file (mojo 1.1.0 builds an imported module holding a malformed def nothing calls with exit 0, measured) — the failed build reported, m0 dev alive, the same pid serving the same literal for two more seconds; the error removed and another literal changed — served by a third pid; SIGINT to m0 dev ALONE, not its process group, so the server sees no signal but the one m0 dev sends — the server gone, nothing listening, exit 0, and no SIGKILL anywhere in the run. test_m0.py holds what the wire cannot see: stop returning only once the pid is gone, the SIGKILL named for a server that ignores SIGTERM, the snapshot seeing an edit, a new file, a deletion and pyproject.toml and nothing under bin/, and a rebuild refused by the preflight attempting no build and leaving the server alone. poe sabotage-scaffold (pre-release) reverts eight rules of dev.py and requires this gate to fail for each |
| N31 | m0 image builds what the scaffold writes under deploy/: docker build -f deploy/Dockerfile from the project root, tagged with the project's name ([project].name, else its directory's) unless told otherwise, then the image's own /app/about.json as the last line of stdout. The builder stage runs what a user runs — a frozen uv sync against the committed lock, then the release build of N24 — and the runtime stage carries dist/ and nothing else: no interpreter, no toolchain, the binary as PID 1. about.json is measured by the image's last layer, which FAILS the build if an interpreter is anywhere in it, and records cpu: what the builder's release build SAID it compiled for, the platform's baseline unless a target CPU was named (native is exit 2). m0 image asks for docker and nothing else — of the one list (N25) only project — because the compiler runs in the builder; there is no docker check, since m0 doctor reads that list (but for the release-only patchelf) and a machine without docker is not a broken one. Docker missing or failing is exit 1 with its output untouched; what follows a bare double dash goes to docker build as it is. It does not deploy (D42) |
verified | Smoke test the m0 scaffold's deploy image (every PR) — a scaffolded views app outside the tree; the Dockerfile and .dockerignore hashed before the build and after, so what is built is what m0 new wrote. The tree's wheel is <version>+tree, which no index serves: it is copied to .wheels/ and the HOST's sync runs with a RELATIVE find-links, which uv records relatively (asserted in the lock) — an absolute one is recorded absolutely and a frozen sync in the builder could never be satisfied — and .wheels/ reaches the builder in a base image handed to docker build, through the pass-through, as a named context that replaces python:3.13-slim. No docker on PATH is exit 1 naming docker, a flag docker refuses is exit 1 with docker's words and an empty stdout, native is 2. Then the user's uv run m0 image: about.json names this app, its version, the daemon's architecture, "python":false and the BASELINE cpu (x86-64-v2 on CI's runner, the first x86-64 build of the scaffold's image); the BUILDER stage's installed m0/ byte-equal to the wheel under test, file by file — the layer cache is uv's version cache again, and the gate does not take the base image's changed digest on trust; run with a published port: /health, /proc/1/cmdline exactly /app/server, the file in the image equal to the line printed, no python* or libpython* anywhere as root, and N27's whole views wire — the 422 fragment included — through the port; docker stop exit 0 inside 10 s; and a second build with a target CPU named, whose about.json says so. poe sabotage-scaffold (pre-release) breaks eleven rules — in image.py, the baseline table, the Dockerfile, and one that plants another m0 in the builder — and requires this gate to fail for each. Not claimed by this gate: that a published m0 resolves in the builder, which no gate can ask of a wheel before it is published — docs/RELEASING.md's after-upload run asks it by hand, and m0 0.2.0's did (2026-09-25); since 2026-09-25 the runtime stage also installs RUNTIME_LIBS (libsqlite3-0, the library m0_sqlite opens at run time; libpq5 by one build argument), records it in about.json's libs, and the gate asks the running container for libsqlite3.so.0 |
| N32 | m0 is published by a workflow of its own, from tags of its own: release-m0.yml has ONE trigger, a pushed tag matching m0-v* — which release.yml's v* does not match — refuses a release Tests has not passed (the latest run on the tagged commit, or on the pull request head a merge commit merges, since a merge the automerge label makes starts no workflow of its own), builds the wheel with no local label (the variable every smoke builds with is unset, and the build does not go through the poe task that defaults it), refuses a wheel whose version carries one, is not the tag's, was cut from another commit or a dirty tree, records other than one mojo and one max-core pin, or declares a dependency, scaffolds once from the built wheel and requires the pins it writes to be the version being published and the MAX it records, and uploads through PyPI's trusted publishing from an environment of its own, pypi-m0, the only job granted an identity token, with the publish action pinned to the commit release.yml pins. Exercised by two tags, m0-v0.1.0 (2026-09-21) and m0-v0.2.0 (2026-09-25), green in both jobs at the first attempt; the Tests gate came after both and passes both, 0.2.0 on its own push run and 0.1.0 on the pull request its merge commit merges |
verified | Check machine-sourced doc facts (every PR) — m0_release_problems in scripts/check_docs.py, a pure function of the two workflows' text with comments stripped: the single trigger, the Tests gate and its success-only pass, the unset and the direct build, the local-label refusal, the tag comparison, one gated_max pin, one identity token, the one environment, the shared pin, and release.yml's patterns tried against m0-v0.1.0. Its selftest reverts thirteen rules against the committed files and counts an unchanged mutation as MISSED. What this cannot show is that the workflow RUNS: only a tag does, a PyPI filename being burned for good, so docs/RELEASING.md orders every tag behind a local run of the build job's own checks — as it did the first, recorded there |
| N33 | The Mojo stack's front door is a page CI executes: packaging/m0/QUICKSTART.md takes a reader from uvx m0 new through uv sync, uv run m0 build, the served application probed over a socket (a document, the bare fragment for an htmx 4 partial, a created item, a 422 that is still the fragment), uv run m0 test, uv run m0 doctor, and uv run m0 dev swapping in an edited string literal while the port keeps answering. The page's image step is display-only and is NOT run here — half of this gate's runners have no docker — and N31's gate builds that image from a scaffolded project. The page lives under packaging/ because this workflow ignores docs/** and root-level Markdown, so a pull request that edits only the page still runs it |
verified | Execute the Mojo quickstart (every PR) — scripts/run_quickstart.py, the mechanism M10 uses, given the tree's <version>+tree wheel: uvx m0 is pointed at the wheel file, the page's uv sync refreshes m0 and is followed by the byte-equality check N27's gate uses (uv caches a version by name), and every block runs in a scratch directory on a stripped PATH — uv's directory and the system's — with this repository's venv unreachable, which the runner asks before the page's first line on every run (a mojo, an m0 or a venv already reachable fails it; with the stripped environment reverted it fails naming the tree's own mojo), so the toolchain the page builds with is the one the page installed. The task pins the page's exact block counts, and a page on which neither substitution matched is refused rather than run against whatever the index serves. Both platforms. Sabotaged by hand before landing, recorded in the note: a wrong command, a wrong expected string on the wire, a lost fence tag, the edit step's old literal |
| N34 | A freshly scaffolded project's first build prints no compiler warning, for every template. The templates compile UNSUBSTITUTED in N28's gate, where __M0_APP__ opening a docstring passes the compiler's summary lint; substituted, a lowercase application name opened the summary and every new project's first build began with a warning. The name is backticked in every entry file |
verified | Smoke test the m0 scaffold (every PR) — the output of the literal uv run m0 build in the written project is searched for warning:, per template; with the backticks removed from one template the gate fails naming the file and the lint. Found by N33's gate on its first run |
| N35 | The Mojo stack's six pages restate lists that live in source, and each is held to it: their six URLs are the ones D45 made permanent and the site's page table serves them there; the host page's refusal table is host_checks' names in the order the host applies them, its flag table the flags in the host's own help text, and its list of m0's checks checks.py's in order; the index's command table is the parser's subcommands, and no code block on any of the pages runs a subcommand m0 lacks; the quickstart's two loop times are the figures the scaffold's AGENTS.md ships in every project; the quickstart's tagged block counts are the ones N33's task pins, so a fence that loses its tag fails on a doc-only pull request; and all six pages are under the bare-figure rule, every figure on them sitting in a block that says where it was observed |
verified | Check machine-sourced doc facts (every PR) — mojo_pages_problems in scripts/check_docs.py, a pure function of the pages, the site's table and six source files; its selftest reverts thirteen rules against the committed texts, in the source or on the page as the rule runs, with two controls, and counts an unchanged mutation as MISSED. What it cannot show is that a sentence ABOUT a check or a flag is true |
| N36 | A query string is built by the layer, not by hand: Query takes pairs in order and writes them on a path (q.on(url_for(X))), names and values percent-encoded as url_for encodes a segment — RFC 3986 unreserved bytes kept, every other BYTE %XX, a space as %20 — so a value that is not UTF-8 is encoded rather than trapped on (G14) and the result never carries what Datastar.swap refuses in a URL; add skips an empty value, so a filter's URL is only what is set and one view has one address, and add_empty keeps the pair whose presence is the meaning. url_for encoded the path alone and the encoder was private, so the first application outside apps/ with a GET filter wrote its own |
verified | test_router.mojo:test_a_query_is_built_encoded_and_only_from_what_is_set (every PR) — its neighbours send the result back through URI.parse (&, =, +, #, % and a space survive), encode bytes that are not UTF-8, and hand a query of hostile request data to Datastar.swap |
| N37 | A swap that arrives at a view can move the address bar: push=True on Fragment.swap, Fragment.el and Html.swap[V] writes the swap and then Vocabulary.push_url on the same element — hx-push-url="true" for Htmx — so a filtered list or a detail reached by a swap can be reloaded, bookmarked and gone back to, the restore being the HX-Request-Type: full request N22 already answers as a document. Two refusals, never a no-op: the LAYER refuses a push on any verb but get, a pushed URL being one the browser GETs on reload (htmx 4's query included, its parameters being in the body); and the trait's default refuses, which is Datastar's answer by name, Datastar 1.0.4's free bundle having no history handling for a back button to meet (D46) |
verified | Smoke test the fragment notes app (every PR) — the list's link to a note (expression tier) and the detail's link back (builder) each carry hx-push-url="true", nothing else in the list does, and the pushed URL asked for as an htmx 4 restore is the note inside a document; each push reverted alone fails the gate at its own line. test_html.mojo holds the bytes in both tiers and the three refusals |
| N38 | A route that takes GET answers HEAD: a HEAD that no route registers for its path reaches the view a GET would, parameters, table and loop placement included, and the server drops the body and keeps the GET's Content-Length (RFC 9110 §9.3.2). A route registered for HEAD itself wins over its table's GET whatever the order they were added in, a path with no GET is still a 405 for HEAD, and Allow names HEAD beside GET. The table matched methods exactly, so every view route, /health included, answered HEAD 405 with Allow: GET, OPTIONS, against §9.1's rule that a server supporting GET supports HEAD; the first application outside apps/ found it on its own deploy |
verified | test_views.mojo:test_head_reaches_the_get_view_in_either_table (every PR) — a HEAD through dispatch, the stateless loop and the loop with the state, never to another method's view; test_router.mojo holds the precedence and Allow. On the wire, smoke-blobs sends HEAD to a read view and a loop route, each answered with its GET's status, length and type and no body and followed by a request on the same connection that must parse (head_probe.py --twin), and to the stream, which ends at its head and leaves no viewer (head_probe.py --hold, L27) |
| N39 | The m0 wheel carries the two storage packages, m0_sqlite and m0_postgres, as source trees beside the five, mapped from the manifest like them (hatch_build.py's table and the smoke's second spelling of it), and an application uses either with no link flag: both open their C library at run time (O17 for libsqlite3; libpq from the start), so m0 build takes nothing and m0 test, which is mojo run and resolves nothing from its own image, opens a database like any other test |
verified | Smoke test the m0 wheel (every PR) — the contents phase holds the seven trees to git ls-files, and the project the smoke builds carries a test that opens SQLite in memory through the installed m0_sqlite and must be green under m0 test, on both platforms |
| N40 | The live scaffold keeps its kick count in SQLite across a restart: store.mojo is one row in a counters table; LiveHandler.make opens the connection once per worker, loop or pool thread, AFTER the fork (a SQLite connection must not cross one), with the schema created inside an IMMEDIATE transaction so two workers over one fresh file do not race; the kick view counts in the database inside its own request, so the count is a committed row by the time the 204 is answered, and /stats reads it back from the file — never a producer writing the board's word back on its next poll, which lost a kick posted just before SIGTERM; the board's word keeps kicks since start for the wave alone. M0_DB names the file, else NAME.db beside the binary, and the image points it under /app/data with a volume the deploy runbook names |
verified | Smoke test the m0 scaffold (every PR) — the scaffold's own smoke.sh kicks, restarts the server on the run's own database file and polls /stats for the kick; the template's test opens the store in memory under uv run m0 test, with nothing linked; sabotage-scaffold reverts the view's write (caught at the wire's first /stats, which reads the database) and swaps the file for an in-memory store (counted, then forgotten: caught only by the restart). The board's word is the wave's half, and the wire holds it too: a kick posted with a stream open must lift every bar of a later frame above the highest bar before it, so sabotage-scaffold's third kick rule, which drops the word, is caught there — against /stats alone, which reads the database, it passed whole (MISSED on the 2026-09-26 pre-release run). The producer-owned write was the first shape and CI's ubuntu leg found it: {"steps":0,"kicks":0} after the restart, the kick having landed inside the producer's 50 ms poll and SIGTERM before the next |
| N41 | MAX is optional beside an application and pinned when present (D50): the wheel records gated_max from the root's max dependency group as it records gated_mojo from the root pin, refusing anything but one exact max-core==X; max-gated is a check in the ONE list after mojo-gated, passing when max-core is absent (naming the uv add that installs it), refusing with the sentence and the fix when another version is installed, passing when the gated one is; the doctor's JSON carries gated_max and max; the scaffold's pyproject.toml carries the uv add line with the gated version substituted (__M0_MAX_VERSION__) and its AGENTS.md the rules — the capture list, where parallelize belongs, threads not forked workers (E32) |
verified | Smoke test the m0 wheel (every PR) — gated_max against the root's group; a stub max-core 9.9.9 beside the REAL mojo, so mojo-gated passes and the sentence is max-gated's alone, from m0 build, m0 test and the doctor; the plain doctor saying MAX is optional when none is installed; the release recipe's bundling of the runtime is E32's gate, where MAX is installed |
| N42 | An upgrade never rewrites a project's files, and says what the scaffold it came from lacks: m0 doctor compares the files m0 new writes that stay the scaffold's — every one but the README and pyproject.toml, which are the application's from its first edit — with what the RUNNING m0 writes for the project's name (its pyproject.toml's, so a moved project keeps it), and names those that differ, reported and never failed, since a difference is an application's edit as often as an older m0's file; a file the application removed is not compared. And the toolchain moves in one command: with max-core installed, mojo-gated's fix is uv add --dev of BOTH pins, because max-core pins its mojo-compiler exactly, as mojo does, and moving mojo alone past it cannot resolve (D52) |
verified | Smoke test the m0 scaffold (every PR) — per template, the written project's uv run m0 doctor --json names no difference, then names deploy/Dockerfile alone once a line is appended to it; sabotage-scaffold (pre-release) removes the naming and must be caught there. The unit forms are test_m0.py's Scaffold class (an edit named and left in place, a removed file and the README not compared, a moved project still matching) and Pair.test_beside_max_the_fix_moves_both_pins, whose sentence was checked against PyPI's metadata: max-core 26.6.0 and mojo 1.1.0 each require mojo-compiler==1.1.0 |
| N43 | One user behind a signed session, as a module of the layer: m0_http.login's Login.from_env(PREFIX, cookie) reads PREFIX_KEY (at least 32 bytes), PREFIX_PASSWORD and PREFIX_SECURE (1 or 0, stated and never assumed: the server cannot see the scheme a proxy terminated, so unset is refused where it used to be read as off, a cookie sent in clear on a first http:// request), with PREFIX_KEY_PREV, PREFIX_USER and PREFIX_TTL (1 second to 400 days) optional, and fails closed, naming the variable, on anything it cannot serve — a user name the cookie cannot carry and a cookie name that is not a token included; sign_in(user, password) is the credential check and the session in ONE call, None for anything but the one user's pair (compared as SHA-256 digests in constant time), returning the session a page renders behind it and the cookie to set; session_of(req) reads a session back, under the previous key while a rotation lasts; sign_out expires it in the client; refuse_signed_out answers a navigation with a 303 to the login page and a swap with a 401 carrying the login form; csrf_refusal answers 403 unless a write carries this session's token in the X-CSRF-Token header or the form's csrf field — never the query string, a header that is present deciding, and a refused session refusing everything; csrf_input and csrf_header spell the token's two places once; and every answer the session chose is no_store. It is the glue apps/fragment_notes wrote by hand and the soak application copied with its names changed (D53); no store and no KDF (D24, D25) |
verified | test_login.mojo:test_a_write_passes_only_with_this_sessions_token (every PR) — the configuration's refusals (an unset SECURE among them, test_secure_is_stated_never_assumed) and what it reads, the credential check, the read-back across a rotation and the two refusals' answers are the file's other tests, and each rule reverted alone fails one of them. On the wire it is N45's, and apps/fragment_notes runs on it: Smoke test the fragment notes app holds it against sessions and forgeries a CPython issuer signed (N13), and poe sabotage-notes-login (pre-release) reverts its CSRF rules in login.mojo itself |
| N44 | A swap can send a request header: header=RequestHeader(name, value) on Fragment.swap, Fragment.el and Html.swap[V] writes the swap, then a push, then Vocabulary.request_header on the same element — hx-headers for Htmx, the name and the value written as JSON strings inside the attribute's HTML escaping — which is how a DELETE carries its CSRF token under htmx 4, whose DELETE sends its fields in the query string (csrf_header(token), N43). The LAYER refuses a name that is not an RFC 9110 token and a value holding a control byte or a byte outside ASCII, which a browser would send as Latin-1; the trait's default refuses, so a conformance with no spelling raises rather than render a swap that sends nothing, and that is Datastar's answer by name, Datastar putting a header inside the action expression swap writes whole. Retires D38 |
verified | test_html.mojo:test_a_swap_sends_a_request_header_in_both_tiers (every PR) — the bytes in both tiers, after a push, with JSON escaping; the refusals are test_html.mojo:test_a_request_header_no_client_would_send_is_refused, test_html.mojo:test_a_vocabulary_with_no_spelling_for_a_header_refuses_one and test_html.mojo:test_datastar_refuses_a_request_header. On the wire, apps/fragment_notes' delete form carries its token this way, which Smoke test the fragment notes app asserts and poe sabotage-notes-login (pre-release) shows failing with the layer's request_header call removed, as does the auth template's delete button (N45); browser-notes-login (pre-release) submits the former from Chromium under htmx 4.0.0 |
| N45 | m0 new NAME --template auth writes the views list behind a login, on m0_http.login (N43): every view but the login's opens with the session guard and every write with the CSRF check — a POST carrying the token as a hidden field, the DELETE as X-CSRF-Token from header=csrf_header(token) (N44) — and the login and the logout are PLAIN forms answered with a 303. One user in the environment (APP_KEY, APP_PASSWORD, APP_SECURE), read in main BEFORE serve, so a missing variable is exit 78 naming it under --doctor too; m0 new prints the export that sets all three, APP_SECURE=0 for http://localhost. The deploy/fly.toml every template writes states APP_SECURE = "1" beside force_https, so a deployed session cookie is Secure with nothing to remember, and the image states none, so a platform that says nothing is refused rather than served in clear. The closed set's third value (D44, retired; D53) |
verified | Smoke test the m0 scaffold (every PR) — written, built, tested and served outside the tree as the others are (N27), then on the wire: signed out, a navigation is a 303 to /login and a swap a 401 carrying the form, neither cacheable; the wrong password a 401 with an alert and no cookie, the right one a 303 setting an HttpOnly, SameSite=Lax session; a write without the token a 403 and with it the item escaped, its delete button carrying the token as hx-headers; the empty title's 422 fragment; the created item in each of twelve later reads, the server run with --blocking-threads 2 (every view that touches the list is on_loop=True; on the pool each thread held a list of its own); a DELETE with the token in its URL a 403, in its header a 200; sign-out a 303 expiring the cookie; and m0 doctor --json without APP_PASSWORD exit 78 naming it. Then the deploy as written: the binary run with the written deploy/fly.toml's [env] and the two secrets fly secrets set adds, and nothing of the run's own, answers a sign-in with a Secure session cookie, and the file forces HTTPS; packaging/m0/tests/test_m0.py holds the same file for every template and the image stating no SECURE. The scaffold's own smoke.sh probes the same with curl, from that refusal on. poe sabotage-scaffold (pre-release) reverts the token check, the header, no_store, the swap's 401, the read before serve, the printed export, the deploy's APP_SECURE, removed and set to 0, and the list read's on_loop (auth: the list is read off the loop), each alone |
| N46 | A resource is its routes, named once: Views.resource(PATTERN, list=, new=, create=, show=, edit=, update=, delete=) registers a collection and its rows under one pattern — GET the pattern, GET /new, POST the pattern, GET, PUT and DELETE /:id, GET /:id/edit — each view in the slot that says what it does, the reading slots taking a view that does not write and the others one that may. Every slot is optional and an empty one registers nothing, so a view of the other kind (a list that fills a cache as it answers) takes the collection's own pattern through add_write. update is registered twice, for PUT /:id and for POST /:id/edit, because a plain form answered with a 303 cannot PUT and posts to the URL it was served from; the form that creates is registered before the row, since the router answers with the first route that matches and :id would capture new. The suffixes are RESOURCE_NEW, RESOURCE_ITEM and RESOURCE_EDIT, which an application joins to its pattern once for url_for. A registration and nothing more (DECISIONS D61): no guard, no controller, views stay free functions |
verified | test_views.mojo:test_a_resource_registers_each_view_under_its_route (every PR) — its neighbours hold the POST twin and both Allow values, /new ahead of /:id, an empty slot's 405 and 404, a writing view on the collection's pattern, the suffixes reversing through url_for, and a mounted table; apps/fragment_notes registers through it with its smoke unchanged, and apps/table_notes with every slot but list (N48) |
| N47 | A rendering is kept until a clock moves, and a GET whose client holds the bytes is answered 304: Cached holds one rendering and the clock value it was made at — current(clock) compares for equality, since a clock is not a count, and fill(clock, body) answers whether the BYTES changed, not the clock — and conditional(req, resp) puts a weak ETag over the response's own body on a 200 to a GET or a HEAD and answers 304 when If-None-Match names it, a list and * included. The tag is a hash of what is sent, so the page and the fragment of one URL have two tags, a shell that changed is a new page, and a clock that moved for a change elsewhere leaves it alone; a validator derived from the clock would move every table's tag on any commit (D62). The 304 carries the tag, Cache-Control, Vary, Content-Location and the 200's cookies, and no content; Cache-Control: no-cache is added only where the response names no policy, so an answer marked no-store keeps the mark; any other status or method, a file body and a stream are returned as given. The module imports no database: the clock arrives as a number |
verified | test_cached.mojo:test_a_rendering_is_current_until_the_clock_moves (every PR) — its neighbours hold fill's answer for a same-length edit and for an empty rendering, the tag and the policy on a first GET, the 304's headers and cookies, the two representations' two tags, the statuses and methods left alone, and a policy the application set |
| N48 | A resource over a SQLite table serves from any number of loops with nothing shared but the file: apps/table_notes holds two connections per thread, a writer and a read-only reader every view renders through, asks the reader's change clock (O25) before answering the list's first page, renders that page only when the clock has moved since it was kept (N47; a later page is rendered each time), and answers a client that holds it 304. A row has one name, its rowid in decimal with no sign and no leading zero, so /notes/07 is a 404, and the key is never reused for another row; a list is a page of rows after a rowid, the continuation that stays stable under inserts and deletes; create and edit are plain forms answered 303, the DELETE the one swap (N46). No lock is held across a render and no bus carries the change: each loop's clock moves for the others' commits, and their tags agree because they hash the same bytes |
verified | Smoke test the table notes app (every PR) — scripts/table_notes_probe.py, at one loop: the list rendered once for a GET, a conditional GET and a HEAD; the fragment's tag refusing to validate the page; the 303s, the 422 with the form back, a JSON body's 400; the server's own write costing exactly one rendering; four spellings that are not a row's name and a row that is not there, each a 404; the edit form posting to itself and PUT reaching the same view; another PROGRAM's commit (CPython's own SQLite) reaching the list at the cost of one rendering; a commit to another table moving the clock, costing a rendering and answering 304 on the old tag; a same-length edit answering 200; the DELETE's fragment; fifty rows and a link to the rest. Then M0_THREADS=2 over one file, on connections held to each loop (the phase fails rather than pass on one): twenty writes through alternating loops, each the next thing the OTHER loop serves, its list and the row, and each loop's tag validating the other's list |
| N49 | A table says what changed since a client last asked, from any loop and with nothing kept for the client: apps/table_notes watches its table (O26) and answers GET /notes/changes?since=N with the rows stamped above N in one statement — a row that lives with its title and born, true when it was created above N; a row that is gone as gone; and head, the last stamp given as of the snapshot that answered, which is where the client asks from next. The floor, the head and the rows are read in one transaction. A row written twice has one entry, at its last stamp; a client that has everything is sent no rows and the same head; a commit to another table moves the clock (O25) and no stamp; and a since below the floor tombstones were pruned to, or above the head (a stamp this database never gave), is answered reset with no rows: a delta from the first could keep a deleted row alive, and a client at the second would hear nothing until the counter passed it. A since that is not decimal digits, or has a sign or a leading zero, is a 400. A title holding bytes that are not UTF-8 goes out with each as U+FFFD, since a delta a strict client cannot decode cannot be skipped. The answer is whole however long: born tells a client it lacks a row only when N was some answer's head, and a table older than its watch is not all in the delta from 0 (O26). The list's cache stays on the clock, which no write gets past (D63) |
verified | Smoke test the table notes app (every PR) — scripts/table_notes_probe.py, at one loop: the delta from 0 holding the table, more than a page of it, every living row born and its head the server's; nothing for a client at the head; a create through the server, an update by another PROGRAM (CPython's own SQLite) and a delete, as three rows in commit order with born, a title holding a quote, and gone; the same asked from the middle, where the created row is no longer in it and nothing is born; a row written twice with one entry at its last stamp; a title with a byte that is not UTF-8 and a control character, decoded strictly; a commit to another table moving the clock and no stamp; five spellings that are not a stamp, each a 400; a client below the floor answered reset, one at it not, and one ahead of the head reset. Then M0_THREADS=2: twenty writes through alternating loops, each the one row of the OTHER loop's delta from the stamp before, and both loops answering that stamp identically |
| N50 | A list over a table is live from any loop, and the server keeps nothing for the page but the stamp it stands at: apps/table_notes answers GET /notes/events with a Feed (N51) on the loop's state (add_write(..., on_loop=True), D32), the first page carries an eight-line script (an EventSource from the stamp the page was rendered at, and htmx 4's htmx.swap), and on every tick the loop asks the clock and, when it has moved, sends each subscriber the rows stamped above where IT stands as one event whose id is the new stamp — a changed row as its <li> with hx-swap-oob="true", a gone one with hx-swap-oob="delete", the new ones inside a <ul id="notes-list" hx-swap-oob="beforeend">, since htmx 4 inserts an out-of-band element's children for a positional swap. A reconnect with Last-Event-ID is the same question; below the floor or above the head the event is resync and carries the first page whole, its script included. Commits inside one tick are one event; a commit to another table is none; a subscriber that stops reading is sent merged events from where it stands, never a cut one and never a gap; a fragment rendered again (the DELETE's answer) opens the feed again from its own stamp, so a row is never appended twice |
verified | Smoke test the table notes app (every PR) — scripts/table_notes_probe.py, at one loop: the first page's script and stamp, and the second page without; an outside insert, the server's own insert, an outside update and a delete through the server, each one event with the right out-of-band form and the head as its id, an appended row keeping its htmx attributes; a commit elsewhere sending nothing; a reconnect from the delete's stamp answered the two changes it missed and no more, and one from the head nothing; a client below the floor and one above the head each answered resync with the page; since=x a 400; a subscriber reading nothing through 68 commits over 400 rows, the feed counting a refusal and, once the subscriber reads, reaching the head with every row at its final title and the eight inserted rows each appended ONCE (the no-gap, no-duplicate check; fewer events than commits is the tick's coalescing as much as the slot's). Then M0_THREADS=2: a subscriber held to each loop, six writes through alternating loops, each the one row of BOTH loops' next event. Chromium applying all three forms, a resync, and an appended row's own hx-delete is a check run by hand (the note) |
| N51 | A stream whose event ids are the application's and whose memory is not the server's: m0_http.Feed holds subscribers, each at a number the application owns (a stamp), and nothing else — no counter, no journal. open(req, key) places a subscriber at Last-Event-ID, else ?since=, else 0 (409 for a request with no slot, 400 for a number that is not one, with no sign and no leading zero), behind(head) names the slots NOT standing at the head — below it, and above it, a number this application never gave — send(slot, head, frames) queues a delta WHOLE and places the subscriber where the application says, down as well as up, and skip(slot, head) places one with nothing of its own. A delta of any size is taken by a slot with nothing pending; one that does not fit beside what is pending under FEED_SLOT_BUDGET is refused and counted, the subscriber stays where it stands, lagging says so, behind leaves it out until its socket has drained something (so a stuck subscriber costs the application no rendering), and the next refresh after that asks the application for a delta from there; open clears what a slot held before, so a stream a raising view half opened leaks nothing to the slot's next client — merged, never cut, since a client holds a row iff the row was born at or below a number that was the head of a snapshot it has all of. ViewState gains the three stream hooks and the tick with the handler's defaults, forwarded by ViewsApp, so a views application can hold one |
verified | test_feed.mojo:test_open_places_a_subscriber_where_it_says_it_stands (every PR) — test_a_delta_is_taken_whole_or_not_at_all, test_behind_names_the_slots_below_the_head, test_send_moves_a_subscriber_to_the_deltas_head, test_skip_moves_a_subscriber_without_a_frame, test_a_closed_slot_is_forgotten, test_open_clears_what_a_slot_held_before and test_since_of_reads_the_header_before_the_query; apps/table_notes is the application on it (N50) |
| N52 | m0 new NAME --template board writes a list every tab shares whose sender is a VIEW: POST /messages reads a urlencoded text (1 to 1500 bytes, else a reply.problem 422; a body that is not a form, 400), keeps the newest fifty in the state, publishes the whole board as ONE datastar-patch-elements frame through the state's DatastarStream(send_latest=True), and answers 200 application/json {"text":""}, the signal patch that empties the poster's input, since Datastar 1.0 applies an action's answer only on a 200 and its morph keeps a typed value whose value attribute did not change. The form is a fragment of its own outside the streamed board, so a frame never clears another tab's draft. Every view that touches the state is on_loop=True, because under --blocking-threads each pool thread builds a state of its own whose stream nothing drains, and the list lives in one process, so max_workers() -> 1 (D66). No producer and no database: the shape every Mojo run of the 2026-10-07 agent usability record built by deleting half of live |
verified | Smoke test the m0 scaffold (every PR) — written, built, tested and served outside the tree as the others are (N27), the server started with --blocking-threads 2: the document holds the form, the board and the pinned Datastar tag; a board of 50 messages of 1500 quotes, which rendered whole is over seven times a slot's 64 KB outbox, still sends the posted message to an open stream (the render keeps the newest that fit a byte budget, test_a_full_board_still_reaches_an_open_stream); two streams open at once each receive the posted message, escaped, inside five seconds; the POST answers the signal patch as application/json; a stream opened afterwards is sent the newest board at once; an empty message is 422 Unprocessable Content problem+json naming /messages; the document holds the board as it stands. The scaffold's own smoke.sh probes two streams with no pool, and its test drains two subscribed slots after a post. poe sabotage-scaffold (pre-release) reverts the send, each of the three views' on_loop, the byte budget (board: the frame is not held to the outbox), the escaping, send_latest, the signal patch and the docstring's backticks, each alone |
| N53 | An Accept entry's weight is its parameter named q, a name matched whole and in any case: parse_accept splits an entry's parameters on ; and takes the first whose name, folded in ASCII, is q (RFC 9110 §12.4.2, §5.6.6), so Q=0 refuses a type as q=0 does and a parameter such as xq=0 is not a weight; whitespace around the =, which the grammar does not allow, is read past rather than misread, so q = 0 refuses and q= 0.5 weighs 0.5 |
verified | test_content_negotiation.mojo:test_the_weight_is_the_parameter_named_q_in_any_case (every PR) — beside it, test_a_parameter_whose_name_ends_in_q_is_not_the_weight and test_whitespace_around_a_weights_equals_sign_is_tolerated. The weight was the first substring q= (review record LF69): Q=0 was no weight, ;xq=0 refused the type, q =0 was ignored and q= 0.5 read as 0. Reverting each rule alone (the substring, the case, the whitespace before and after the =, the parameters after the first) fails one of the three |
O. Storage packages
m0-sqlite and m0-postgres are siblings of m0-core and m0-http, not
layers on them: each binds one database client and depends on nothing else
here. These rows are the invariants that look like bugs and are not — the
ones a well-meaning simplification removes — plus the wire-level claims that
an application's data survives the process and that a query means what it
says. The performance study behind the SQLite package is
SQLite performance; the layout question is
SQLite virtual tables.
O19 to O22 are the scalar functions an application registers from Mojo
(Connection.create_function), which run inside SQLite's query plan; O23 is
the one library image they depend on, which the loader did not deliver on
macOS until they found it, and which covers the libraries this package
opens, not a copy of SQLite something else brings into the process
(docs/notes/functions-inside-the-query.md).
The Postgres rows split by what each needs. O6 to O8 and O31 are pure
functions of bytes and text and run in poe test-all on every leg. O9 to
O16 need a real server, so they cite a Linux-only job with a service
container rather than a test function: GitHub's service containers require a
Linux runner, which is a platform fact and not a preference, and the spec
checker rightly refuses a cited step that carries an if:. Those tests fail
without a server and never skip. O24 needs libpq and no server, and only
macOS can fail it, so it cites a macOS job that installs the library: the
runner image's software list names none
(docs/notes/libpq-keeps-its-handle.md).
| id | capability | status | evidence |
|---|---|---|---|
| O1 | open promises WAL and raises when it cannot deliver it: PRAGMA journal_mode answers with the mode actually in force, and a database that stays on a rollback journal — a temp or in-memory database, some network filesystems — is an error at open rather than reader/writer concurrency that silently is not there; and two processes opening one fresh file at the same moment both get it, because the switch is retried on SQLITE_BUSY within the busy timeout, which SQLite does not consult for that pragma |
verified | test_file.mojo:test_open_reports_wal_it_could_not_apply (every PR) — the discarded row is the defect: open would promise WAL while delivering a lock that serializes, which surfaces later as inexplicable contention rather than as an error here; test_two_processes_open_one_fresh_database beside it forks twelve pairs that open one fresh file at once, and about one pair in two lost the race on an M4 before the retry (29 of 30 two-worker starts of apps/datastar_todo refused, seen the day the Mojo host stopped hiding a raising make behind a respawn, E25) |
| O2 | A Statement outlives the Connection that prepared it, because every close path uses sqlite3_close_v2: Mojo destroys a value at its last use, so a connection whose last mention was prepare() is already closed while its statement is still stepping |
verified | test_sqlite.mojo:test_statement_outlives_its_connection (every PR) — sqlite3_close would refuse the close and leave the handle open, or break the statement, depending on the path; close_v2 finalizes as statements are released |
| O3 | Error text is only trusted when sqlite3_errcode corroborates the code being described: a closed connection answers SQLITE_MISUSE to every question, so a true "UNIQUE constraint failed" must not be replaced by a false "bad parameter or other API misuse" |
verified | test_sqlite.mojo:test_step_error_stays_truthful_without_a_live_connection (every PR) — the corroboration holds only because nothing calls sqlite3_extended_result_codes; turning those on makes step return 2067 where errcode still answers 19, and every message would degrade to the generic text |
| O4 | m0_array(?) streams a Mojo List into SQL without copying it, and the borrow cannot outlive the data: an array is bindable only through the helpers that take it as an argument and finish the statement before returning; and the pointer-type tag bound with the array is the literal's own static storage, never a buffer freed after the bind, because SQLite keeps the pointer and compares against it when the statement steps |
verified | test_vtab.mojo:test_borrow_is_dropped_after_the_call (every PR) — the C struct offsets the module depends on are asserted against the real sqlite3.h by extraction before any Mojo test runs, and moving any one of them is caught, both inside poe test-all. test_the_tag_sqlite_keeps_is_not_a_freed_buffer beside it fills the heap between the bind and the step: 1.7.0 bound a buffer freed as the call returned, and an INSERT ... SELECT from the array inserted 0 of 100 rows, silently, in 30 runs of 30 on macOS. On Linux (glibc 2.36, 2026-09-29) the same revert is caught 30 of 30 under mojo run and built alike, with or without MALLOC_PERTURB_: glibc hands the freed block to the next allocation of its size, so no fill is needed. The public helpers step at once, and the lookup's own copy of the tag took the freed block back and rewrote it, which is why nothing else noticed |
| O5 | An application persists in SQLite through a per-thread connection and comes back with its data after a kill and a restart, including the event log that makes SSE replay work across processes | verified | Smoke test the Datastar todo demo (every PR) — apps/datastar_todo is killed and restarted on the same database file, the struck-through todo is still in the list, and a client reconnecting with Last-Event-ID is caught up by the restarted process from the persisted log |
| O6 | Binary results decode to what the server means: integers big-endian and sign-extended from their OWN width, IEEE 754 floats at both widths, jsonb's version byte read rather than skipped, uuid rendered canonically, and timestamps counted in microseconds from 2000-01-01 rather than from the Unix epoch |
verified | test_wire.mojo:test_negative_integers_are_sign_extended_from_their_own_width (every PR) — the table is hand-built bytes, so it needs no server; int2's -1 read as unsigned is 65535, and a timestamp read against the wrong epoch is thirty years early and entirely plausible |
| O7 | Connection defaults — among them TCP keepalive timings and tcp_user_timeout, so a dropped idle connection is noticed in about a minute rather than the OS's two hours — are merged, never appended, and every URL is redacted before it reaches an error, a log or the doctor: libpq takes the LAST occurrence of a repeated keyword, so appending a default silently overrides the caller's own. A URI that already ends in ? or & gets no second separator, which libpq refused before connecting, reading ??connect_timeout=5 as a keyword named ?connect_timeout and && as an empty one |
verified | test_url.mojo:test_a_default_the_caller_set_is_not_overridden (every PR) — the redaction arm is test_url.mojo:test_a_password_in_the_authority_is_masked, covering both places libpq lets a password hide, plus a table of hostile strings each asserting only that its secret is absent from the output — an unencoded /, ?, @ or : in the password, a query or key/value form libpq itself would refuse, quoted and spaced values, sslpassword and oauth_client_secret — because a string redact cannot parse comes back masked whole, and libpq's own message beside it is withheld, since libpq quotes what it cannot parse (measured: half the password on every listener retry, before the fix fifteen of those rows leaked). The read-only helper refuses to half-apply its promise rather than adding a second options keyword that would replace the caller's. The separator arm is test_url.mojo:test_a_uri_ending_in_a_separator_is_not_given_a_second |
| O8 | Every error carries its SQLSTATE in its message and sqlstate() recovers it, with the context before the code so appending can never make it unrecoverable; the two predicates an application branches on — retryable, connection lost — name their classes once |
verified | test_sqlstate.mojo:test_the_context_goes_before_the_code_not_after (every PR) — the same ordering rule m0-sqlite's describe_in keeps for its result code, and for the same reason: three call sites there once came to raise without one |
| O9 | libpq is opened at run time rather than linked, so no binary in this repo carries a libpq dependency, and an absent library is one error naming every path tried; the function table survives being moved, which is what the whole PgLib shape exists for |
verified | Test m0-postgres against a real server (every PR) — a thin pointer loaded from a handle is identical by address before and after a move, and calling it as table.field() from outside the struct that holds it jumps into unmapped memory while the same call from a method beside it answers correctly, so every entry point is private behind a wrapper; test_lib.mojo asserts the working shape in the position the broken one failed in, because a dangling call is a segmentation fault rather than an exception |
| O10 | Parameters are bound, not interpolated: a value carrying a quote and a semicolon arrives whole, every binding method round-trips through a real column, and NULL stays distinguishable from an empty value — which it is not once both have length zero. A text value arrives whole, so a NUL inside one is the server's refusal (SQLSTATE 22021), never a match against the value cut short at it: libpq reads a text-format parameter with strlen, whatever length it is handed, so text() travels binary. SQL text, a statement name, a name to quote, a literal and a connection string, each of which libpq reads as a C string, are refused before the call when they carry one, under the same state |
verified | Test m0-postgres against a real server (every PR) — test_postgres.mojo, in a schema named for the connection's own backend pid so parallel runs cannot collide. Before the fix, admin, a NUL and x matched the admin row |
| O11 | For int8, bool, text, uuid and jsonb columns the same query reads the same in text and binary modes (jsonb less the version byte its binary form carries), so for those types binary is a performance decision rather than a semantic one; a binary float8 decodes to the double the server holds, and a binary timestamptz read with int() is its instant in microseconds from 2000-01-01; a binary type with no decoder raises naming the way out instead of returning plausible bytes. timestamp, timestamptz, bytea, float4 and float8 do not read the same in both modes: a known issue |
verified | Test m0-postgres against a real server (every PR) — numeric is text-only on purpose: its binary form is a base-10000 digit vector with its own NaN encoding, and a wrong decoding of one is silently a different number. Until 2026-09-28 the row claimed every type; review B18 read a timestamp, a bytea and a float4 both ways and found each different |
| O12 | A failed statement carries its SQLSTATE and the SQL, and leaves the connection usable: a constraint violation is reportable as one rather than as a 500, and the connection answers the next query | verified | Test m0-postgres against a real server (every PR) — a transaction left INERROR still reads as open, because it still holds its locks and only ROLLBACK ends it |
| O13 | A read-only connection refuses a write, whatever the role could do: the belt to a read-only role's braces, so a mistake in the grants is an error on the connection that should not be writing rather than a write that succeeds | verified | Test m0-postgres against a real server (every PR) — SQLSTATE 25006, distinguishable from the privilege error a role-level refusal gives |
| O14 | An identifier put into SQL text is quoted by the SERVER, against the connection's own encoding, not by doubling quotes by hand — which is what LISTEN and DEALLOCATE need, taking no parameters |
verified | Test m0-postgres against a real server (every PR) — a channel name is frequently application input |
| O15 | LISTEN/NOTIFY delivers with its channel, payload and notifying backend, nothing is delivered before it is sent or twice after, a reset restores every subscription, and a reset whose re-LISTEN fails on a channel still re-LISTENs every other, then raises once naming each channel that failed, so the rest are listening when it raises, and forgets none, so the next reset tries them all again; a notification read during a statement's round trip is still delivered by the next drain even though poll never reports it — which is why the --pg-listen thread drains once after connecting and after every reset — and a lost connection is reported only once every notification libpq already holds has been delivered, since the recovery, reset, frees them |
verified | Test m0-postgres against a real server (every PR) — the reset arm is the one that is invisible when wrong: PQreset opens a new backend session listening to nothing, so a listener that reconnects without re-LISTENing runs forever delivering nothing and logging no error, which looks exactly like nobody publishing. Review S3 (2026-10-10) found the same silence one step later: a reset emptied its list before re-listening, so one that failed part-way left every later reset restoring fewer channels, and the branch review after it found the reset stopping at its first failure, every channel after that one deaf until the application withdrew it; and S2 found a burst followed by the server's close losing all but its first notification, because notifies read the socket before taking what libpq had queued |
| O16 | A Result outlives the Connection that produced it: Mojo destroys a connection at its last use, so var rows = db.query(...) with no later mention of db finishes the connection on that line, and every read and the final PQclear still answer — because libpq, once opened, is pinned for the life of the process, and a result holds the entry points it calls by value rather than through its connection's address. The bytes Result.raw hands back borrow their result in turn, so the compiler keeps it alive for as long as they are read |
verified | Test m0-postgres against a real server (every PR) — measured before the fix as a segmentation fault on the first read, three runs out of three, in both shapes the test holds: a connection whose last mention is the query, and a result returned out of the helper that opened its connection. Each half of the fix is necessary on its own: pinned but still reading through the address, the read faults in Result.text; unpinned but holding the pointers by value, it faults too, and test_lib.mojo asserts that half without a server by calling a copied table after its PgLib is gone. The span arm is test_a_raw_cell_keeps_its_result_alive: with the span's origin untracked, a result last mentioned at the raw call was cleared on that line and the span read the next query's value, eight runs of eight on macOS, built and under mojo run |
| O24 | A process maps ONE image of each libpq file it opens, however connections come and go, and none of them in the loader's global scope. The first PgLib of an image pins it (O16) and keeps that handle open for the life of the process, because macOS drops an image opened RTLD_NODELETE from dyld's list once its last handle closes, leaving it mapped — which is why O16 held — so the next open of the same path mapped a fresh copy of libpq and of the libraries it brings. For libpq that is a leak, one mapping of the library per close-and-reopen, where for SQLite it was corruption (O23). An image is pinned once: a later open of it is a comparison, with no second dlopen, no second record and no second handle. A second libpq file is a second image, pinned beside the first and not refused (DECISIONS D49). That is sound because every handle, the open's and the pin's, is RTLD_LOCAL: with the package's image in the global scope, as it was, glibc bound the internal calls of a second libpq build into the first, and a connection through the second's own handle crashed — and the second need not be one this package opened: a Python application on psycopg-binary, loaded beside it as it is under m0serve, ran on the system's libpq and not the one it shipped |
verified | Test m0-postgres's library pin where the loader can drop it (every PR) — on macOS, the one platform that can lose the kept handle, in a job of its own (postgres-macos) that installs Homebrew's libpq itself: the runner image's software list names no PostgreSQL (macos-26-arm64 of 2026-09-07), and though the image carried libpq 18.6 on the job's first run, nothing promises it; no libpq on macOS is in the dyld shared cache, and test_pin.mojo fails without a library, so the gate cannot pass by finding nothing to test. Its first test copies a table out of a PgLib, lets every handle go and reopens five times, telling the image by PQlibVersion's address, read and never called, since a pointer into a dropped image keeps answering, and requires one pinned record after as before; its second opens a copy of the library's own file as a second image and requires it pinned exactly once, reopened to the same image and answered from; its third asks the loader, through the process's own handle, that libpq is not in the global scope. Measured before the fix with Homebrew's libpq 17.6: a new image at five reopens of five, and nine images, libpq and the eight it brings, out of dyld's list at each close. The Linux postgres job runs the same file inside Test m0-postgres against a real server, where glibc keeps a RTLD_NODELETE object findable: the kept handle is only stated there, and the rest can fail. What the third test holds is the scope, not the fault it prevents, which needs a second BUILD of libpq: measured by hand on Ubuntu 24.04 (aarch64) with its libpq 16.15 first and psycopg-binary 3.3.6's bundled 18.6 second, 69 symbols of the second bound into the first and a connection attempt through it crashing three runs of three with the first image global, none bound and a clean refusal with it local, and psycopg-binary reporting the system's version in the first case and its own in the second, with no crash measured; under a real m0serve --pg-listen serving a psycopg-binary application the same two versions, 160015 before and 180006 after, with every query answered on both sides; no other pair of builds, x86-64 or TLS was measured. The application's side of the fault has a gate: the Linux job's Smoke test a Postgres NOTIFY reaching a held stream serves an application on psycopg-binary beside the listener and requires it to report the libpq it was built against, with two libpq files mapped and the system's a different version. poe sabotage-postgres-pin, the macOS job's next step and a step of the Linux job, reverts the pin itself, the kept handle, the pin taken once, the per-image rule, @no_inline on the word and the local scope of the open and of the pin, each caught on macOS; on Linux the kept handle reports SKIPPED, the test passing without it there, and the other six are caught (docs/notes/libpq-keeps-its-handle.md) |
| O31 | A text-mode integer reads as the value the server sent or raises: Result.int parses an optional sign and decimal digits and nothing else, and a value outside 64 bits raises rather than wrapping — -9223372036854775808 parses and 9223372036854775808 does not |
verified | test_wire.mojo:test_a_text_integer_past_64_bits_raises_rather_than_wrapping (every PR) — the parse is pure, so it needs no server. The sum() of an int8 column is numeric, which text mode sends as however many digits it has, and until review S8 (2026-10-10) a total past 2^63 - 1 read as a plausible number: 2^64 as 0, and one below the minimum as the maximum |
| O17 | libsqlite3 is opened at run time rather than linked, so no binary in this repo carries a libsqlite3 dependency on its link line — mojo run works on Linux as it does on macOS, which is the shape m0 test takes, and m0 build needs no flag for it — and an absent library is one error naming every path tried, M0_LIBSQLITE3 naming the file outright; a library that opens and is not libsqlite3, or one older than 3.20.0, is refused naming the first symbol it lacks, never aborted on; a library built with SQLITE_THREADSAFE=0 is refused, since the package opens one connection per thread; and every entry point is checked before it is loaded, in one place, taking its name from the declaration it loads |
verified | test_lib.mojo:test_the_library_opens_and_meets_the_floor (every PR) — the search path, the floor and the thread-safety flag; test_a_symbol_the_library_lacks_is_an_error_naming_it, test_a_path_that_is_not_a_library_is_an_error_naming_it (through M0_LIBSQLITE3 too, which is what a Connection sees) and test_a_library_that_is_not_libsqlite3_is_refused_by_symbol beside it. build-apps (inside poe test-all) builds apps/datastar_todo, the package's one consumer, and fails if its binary names libsqlite3 in a NEEDED entry or a load command, which is where an external_call put back anywhere in the package would reappear |
| O18 | A Statement outlives every handle a connection opened the library through, not only the Connection that prepared it (O2): each connection holds its own library (SqliteLib, the handle and its table of entry points in one struct) and each statement a COPY of that table, whole (SqliteFns), which is sound only because the library is pinned for the life of the process — re-opened RTLD_NODELETE by the process's first connection, so no dlclose unmaps it, with that handle kept open (O23); the virtual-table callbacks hold a copy the same way, in the buffer SQLite owns, which is how a callback invoked from C reaches the library without a global |
verified | test_lib.mojo:test_a_statement_outlives_every_handle_of_the_library (every PR) — the connection is closed and dropped, and the handle it opened the library through with it, and the statement then steps and reads through its copies. The sabotage is the pin removed from SqliteLib.__init__: measured 2026-09-25 on Linux, where nothing else has the library mapped, the same test then dies of a segmentation fault at its first step rather than failing (docs/notes/sqlite-at-run-time.md) |
| O19 | A Mojo type registered with Connection.create_function answers SQL calls from inside the query plan: each registration reaches its own instance, whose fields are the function's state and which call may write (mut self: what a call writes is there for the next, a call that raised included), a call that arrives while one on the same instance is in progress — a function stepping a statement of its own connection that calls it — is refused as that statement's error and never run, every storage class arrives and is answered as itself, a blob argument is read where SQLite holds it and its span stays good for the whole call — text refuses a BLOB, since reading one as text converts it in place, which frees the bytes of a blob the statement computed and moves a bound one — an empty blob arrives as an empty span and is answered as a zero-length BLOB, never NULL, a raise fails the statement with its own text — except on a statement stepped after its connection's last mention, a closed connection, where the error keeps its code and carries the code's own text, as every error of such a statement does (O3); keep the connection mentioned past the step to get the function's text — the type's arity is what SQLite refuses a wrong call by at prepare while an index past what a call passed is refused by Args rather than read, and deterministic is registered, so a call with constant arguments runs once per statement |
verified | test_scalar_function.mojo:test_every_storage_class_goes_in_and_comes_back (every PR) — test_a_function_answers_from_its_own_state, test_a_function_keeps_what_it_writes_to_itself, test_a_call_inside_a_call_is_refused_not_run (an instance copied in and one passed by pointer), test_two_functions_calling_each_other_are_refused_at_the_second_entry, test_an_empty_blob_is_answered_as_a_blob, test_a_blob_span_stays_good_because_text_refuses_a_blob (on a computed blob, whose bytes would be freed, and a bound one, which moves; one read off a table page does neither), test_a_raise_is_the_statements_error, test_a_raise_on_a_closed_connection_keeps_its_code_and_loses_its_text, test_sqlite_holds_the_arity_and_args_holds_the_index and test_a_deterministic_call_with_constant_arguments_runs_once beside it, and poe sabotage-sqlite-function reverts the re-entry check, its mark, its clearing and where the clearing sits, the arity, the index check, both refusals of the wrong class, each of the two empty-blob guards, the flag, the error path and the corroboration that keeps a closed connection's text out of an error, each caught. Measured on 100,000 rows of 384-float embeddings: one to three percent over the same function written by hand against the C API (0.8 to 2.5 ns a call across two sessions on one machine) and twice as fast as sqlite-vec's vec_distance_l2, which copies both vectors on every call (docs/notes/functions-inside-the-query.md) |
| O20 | The schema never computes with a function registered through m0-sqlite: every registration carries SQLITE_DIRECTONLY, so SQLite refuses it in a CHECK constraint, a generated column or an expression 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, while top-level SQL and a TEMP view, the connection's own, may call it. Creating a view, a trigger or a DEFAULT that NAMES it is not refused — SQLite resolves those when they run — and a trigger that does fails every write to its table, from every program, until it is dropped. Two floors keep it: a library older than 3.31.0, where the flag does not yet refuse the constraint, the column and the index, is refused; and a function that says it is NOT deterministic is refused on a library older than 3.50.0, where a CHECK constraint naming one is created and run |
verified | test_scalar_function.mojo:test_the_schema_cannot_call_a_registered_function (every PR) — test_a_trigger_naming_a_function_locks_its_table_for_every_writer beside it holds the limit on a database file two connections share: the one that registered the function is refused "unsafe use", one that never heard of it "no such function", reads go on working, and a DROP from either ends it, the registering connection once it has reread the schema. test_a_function_that_moves_needs_a_library_that_keeps_it_out_of_a_check holds the second floor, whichever side of 3.50.0 the library is on: refused naming the library below it (Ubuntu 24.04's 3.45.1, where the hole was measured), registered and refused in a CHECK at or above it. No index, constraint or stored column holds a registered function's results, and a database file from elsewhere cannot reach application code through its schema (D59). The sabotages drop the flag and the second floor and each test fails, the second only on a library older than 3.50.0; the 3.31.0 floor has no test, every library CI runs being newer |
| O21 | SQLite owns a registered function's state from the call on and destroys it exactly once: when the function is replaced; when the connection is destroyed, which is close() unless a statement of it is still outstanding — every close is sqlite3_close_v2, so the connection then lives until that statement is finalized, the function answers from it until then, and the state is destroyed at the finalize; and when a registration is refused — by create_function_v2 itself, for a name over 255 bytes, an arity over the library's limit or a replacement while a statement is active, which is why the package never frees on failure. A name that is empty or holds a NUL byte is refused before SQLite sees it, and a refusal carries the connection's message only when the connection's own code agrees with it |
verified | test_scalar_function.mojo:test_sqlite_destroys_the_state_exactly_once (every PR) — test_the_state_lives_until_the_last_statement_is_finalized and test_a_refusal_before_sqlite_and_a_refusal_in_its_own_words beside it. The sabotage that frees a refused registration again went unseen by the test's first version: the allocator keeps its free-list links in a freed block's first words, where the counter's address sat, so the second destruction wrote through a link. The fixture holds the address past them, and the double free is caught; the name checks and the message rule are each reverted and caught too |
| O22 | Every callback reaches the library through ONE process-wide word, because a scalar function gets its user data only through sqlite3_user_data, itself a library call: published once by compare-and-swap, read back through the callbacks' own accessor before the first registration so a toolchain that gave the writer and the reader different words is a refused registration rather than callbacks reading zero, and refused for a table of another libsqlite3 image, naming the file to keep; an application's conformance compiled against m0_sqlite.mojoc registers and answers |
verified | test_scalar_function.mojo:test_one_word_reaches_every_connection (every PR) — test_one_image.mojo:test_a_table_of_another_image_may_not_register holds the image refusal, with a table built straight from a handle now that the loader refuses a second image first (O23): a copy of the library on Linux, whichever of Apple's and Homebrew's builds the process is not using on macOS (without Homebrew's the test says so, and under CI fails: the macOS unit-gates leg installs it). poe check-sqlite-function, inside poe test-all, builds an application's function against the compiled package, where a lost witness table (D28) or a word lost in the precompile would show. The sabotage removing @no_inline from the word's accessor is refused at the read-back |
| O23 | Every connection this package opens in a process calls into ONE image of libsqlite3, the first it opened: that image is pinned with a handle kept open for the life of the process — macOS drops an image opened RTLD_NODELETE from dyld's list once its last handle closes, leaving it mapped, which is why O18 held, so the next open of the same path mapped a fresh copy — and a library that is another image is refused where it is opened, naming both files, before anything of it is pinned. Every handle is RTLD_LOCAL, so a second SQLite that something else loads is not bound into this one. SQLite forbids two copies on one database file: each keeps its own list of open files, so a close through one drops the POSIX locks the other holds. The package cannot see a copy it did not open, and one is routine: CPython's sqlite3 module carries its own SQLite in the interpreters uv installs, so inside m0serve a Mojo mount on m0-sqlite beside a Python application on the stdlib backend is two copies in one process, safe only while the two never open the same database file |
verified | test_one_image.mojo:test_one_image_however_connections_come_and_go (every PR) — test_a_second_image_is_refused_at_open, by name and through M0_LIBSQLITE3, and test_another_copy_of_sqlite_is_not_bound_into_this_one beside it, in a process of their own that opens a library backed by a file first — the system one on Linux, Homebrew's on macOS. Measured with Homebrew's build before the fix, a new image at every reopen; Apple's library lives in the dyld shared cache, where no image is dropped, and glibc keeps a RTLD_NODELETE object findable, which is why no gate saw it. Measured on Ubuntu 24.04's build, a copy loaded beside a first image in the global scope answered "no such vfs" to every open. The macOS arm is installed, not found: the unit-gates job runs brew install sqlite on that leg, and under CI each test that cannot be exercised for want of the library fails where a developer's Mac is only told. The sabotages drop the kept handle (caught on macOS with Homebrew's SQLite), the refusal (caught wherever a second image loads) and the local scope (caught on a build that binds through the global one, Ubuntu's), and report SKIPPED elsewhere (docs/notes/functions-inside-the-query.md) |
| O25 | A change clock needs no commit hook: Connection.data_version() is PRAGMA data_version, which differs after ANOTHER connection commits a change to the main database file and stands for the asking connection's own writes, so a connection opened read-only beside the writer sees this process's commits like any other's and its answer keys a cache. It moves for a row written in or out of a transaction, DDL, user_version, a DELETE and a VACUUM (which can renumber rowids, and which a commit hook never sees); it stands for a read, a rollback, an empty write transaction, an UPDATE of no rows, a TEMP table, a write to an attached file and a refused INSERT, the four middle ones being what a hook over-counts. It moves with nothing a reader could see behind it for a truncating checkpoint, and for a write that stores what was already there, which is why a validator hashes what was rendered (N47). A connection holding a read open keeps its snapshot and its clock together, so the clock is asked on the connection that renders, before the rendering (D60) |
verified | test_change_clock.mojo:test_a_reader_sees_every_change_the_writer_commits (every PR) — test_a_reader_stands_still_for_what_changed_nothing, test_a_truncating_checkpoint_moves_the_clock_and_changes_nothing, test_a_writer_sees_another_connections_commit and test_a_clock_moves_with_its_own_snapshot, on Apple's libsqlite3 and Ubuntu's; apps/table_notes is the application on it (N48) |
| O26 | The database remembers which rows changed and in what order: install_stamps(db) creates m0_changes(tbl, row, seq, born, gone) and the counter m0_stamp(seq, floor), and watch(db, table) puts four triggers on a table whose key is its rowid, after which every row inserted, updated or deleted there — by this program or any other, a trigger being the schema's — has ONE entry holding the stamp of its last change, the stamp of the insert that made it (born; 0 for a row older than the watch, which has no entry until it is written) and whether it is gone. Stamps are given in commit order and a rollback takes its own back, the counter being a row and SQLite having one writer; a reader's statement sees whole commits, so the entries above a stamp are a whole number of transactions; an update that moves the key is a death and a birth, and a rowid given again after its row was deleted is born again; two watched tables share one sequence. stamp_head is the last stamp given, stamp_of(db, table) the highest among one table's rows — a table's clock, compared for equality since pruning can lower it — and prune_stamps(db, below) forgets tombstones at or below a stamp and raises stamp_floor to it in a transaction of its own, never lowering the floor, never raising it past the head, never freeing a stamp, and leaving no transaction open when it raises, with the error that stopped it even where SQLite cancelled the transaction itself (SQLITE_FULL, SQLITE_IOERR, SQLITE_NOMEM), where a ROLLBACK raised "no transaction is active" in its place. The conflict clause of the statement that fires a trigger (UPDATE OR REPLACE, INSERT OR FAIL) replaces the clauses inside it, so the entry is written by a statement that cannot conflict, and by no upsert, which a SQLite older than 3.24 could not parse. A table is filed under the name the schema spells, whichever case the caller used, and its key is used by its own name, a column being free to be called rowid. install_stamps and watch are idempotent. The two tables are a contract: a program that is not Mojo reads them with SQL |
verified | test_stamps.mojo:test_every_insert_update_and_delete_is_stamped_in_order (every PR) — test_a_rollback_takes_its_stamps_with_it, test_a_reader_beside_the_writer_sees_whole_commits, test_born_tells_an_insert_from_an_update, test_a_row_older_than_the_watch_is_born_at_zero, test_install_and_watch_are_idempotent, test_a_writers_conflict_clause_does_not_reach_the_entry, test_a_table_is_filed_under_the_name_the_schema_spells, test_pruning_forgets_tombstones_and_never_a_stamp, test_pruning_is_a_transaction_of_its_own, test_a_cancelled_prune_raises_the_error_that_cancelled_it (a trigger's RAISE(ROLLBACK) cancelling the transaction, since a capped max_page_count cannot make the DELETE fail: it frees pages and needs none) and test_two_watched_tables_share_one_sequence, on Apple's libsqlite3 and Ubuntu's; that a trigger fires for another program is the wire gate's (N49), whose other program is CPython's SQLite |
| O27 | What a stamp cannot see is refused or pinned, never guessed at. watch raises for a name that is not a plain identifier, for a table that is not there, for a view, for this module's own tables, and for a table whose key is not its rowid — no key, a TEXT key, a pair, INT PRIMARY KEY, a WITHOUT ROWID table (whose triggers would resolve new.rowid only when they fired, and then fail every write from every program) and INTEGER PRIMARY KEY DESC, which SQLite keeps as a key that is not the rowid — the rowid alias being the one name a VACUUM leaves alone. A REPLACE that displaces a row through a UNIQUE column, INSERT OR REPLACE or UPDATE OR REPLACE, deletes it with NO tombstone unless the writing connection has PRAGMA recursive_triggers = ON, SQLite firing no delete trigger for it otherwise: a writer that uses REPLACE sets the pragma, in every program. A table rebuilt by a migration (copy, drop, rename) loses its triggers with no error; watched(db, table) answers False, and watch again restores them without stamping what was written between. An UPDATE that stores what was there is stamped, a trigger being unable to say no column differs without naming them, and an UPDATE of no rows is not |
verified | test_stamps.mojo:test_replace_through_a_unique_column_needs_the_writers_pragma (every PR) — test_a_rebuilt_table_is_no_longer_watched, test_an_update_that_changes_nothing_is_stamped and test_watch_refuses_what_it_cannot_name_rows_in, fifteen refusals among them |
| O28 | A stage keeps its place in the database, with its outputs: m0_cursors(name, seq) is created by install_stamps (a file installed before it existed gains it), cursor(db, name) answers the last stamp a named stage acted on, 0 for one with no place, and advance(db, name, seq) records it WITHOUT a transaction of its own, so a stage calls it inside the begin_immediate transaction that writes what it derived and the two hold or fail together: a rollback takes the place back with the outputs, a reader beside the writer sees both move at once, and a crash between them cannot happen; a refusal leaves the caller's transaction open. A cursor does not go back and does not pass the head — a seq below the cursor, above the last stamp given or negative is refused, in the one statement that writes, so two writers racing on one name in autocommit cannot undo each other — and a stage that starts over deletes its row. slowest_cursor(db) is the place of the stage furthest behind, or the head when no stage has one, and is the stamp prune_stamps may be given without taking a deletion from under a REGISTERED stage: a stage registers before its first read (advance to 0), a retired one deletes its row or pruning stops at its place, and a stage's lag is the query (SELECT seq FROM m0_stamp) - seq. Asked for by a Python stage outside the tree (the embedder of ideas/changefeed); no Mojo stage in the tree holds a cursor yet |
verified | test_stamps.mojo:test_a_stage_keeps_its_place_with_its_outputs (every PR) — test_a_cursor_does_not_go_back_or_past_the_head (two connections among them), test_the_slowest_cursor_is_the_safe_prune_point and test_a_file_installed_before_cursors_gains_them |
| O29 | A closed connection, or a finalized statement, is never passed to SQLite: close() leaves the handle NULL, and in_transaction, last_insert_rowid, changes and total_changes then answer False or 0 without calling SQLite, while busy_timeout, register_array_module and create_function raise naming the closed connection, before SQLite sees the handle or the module buffer is allocated. The entry points behind those seven check for a NULL handle only in a library built with SQLITE_ENABLE_API_ARMOR: Debian 12's, the deploy image's base, is not, and dereferences it, so a call after close() killed the process with SIGSEGV; Apple's and Homebrew's are, and answered in_transaction() True and leaked the module buffer. Everything else a closed connection is asked reaches an entry point that checks the handle whatever the build (sqlite3_exec, sqlite3_prepare_v2, so execute, prepare, query_scalar and data_version raise SQLITE_MISUSE), or a wrapper of the package's own that answers for NULL (errmsg, errcode). Statement.finalize() leaves the statement handle NULL the same way, and sqlite3_clear_bindings and sqlite3_column_name check for it only under the armor too, so clear_bindings and column_name raise naming the finalized statement; every other entry point a finalized statement reaches refuses NULL whatever the build: step and the binders with SQLITE_MISUSE, reset and finalize as no-ops, and the column count as 0, so every column read is refused as out of range |
verified | test_sqlite.mojo:test_a_closed_connection_is_never_passed_to_sqlite (every PR) — the test proves itself differently on the two platforms. On macOS, with every guard reverted, it fails at in_transaction, which Apple's armor answers True, and reverting the busy_timeout or the register_array_module guard alone is caught by its message; the three other accessors' guards show only on a library without the armor, where the unguarded call dies of SIGSEGV: measured in a Debian 12 container, and Ubuntu 24.04, CI's Linux leg (no SQLITE_ENABLE_API_ARMOR; a NULL handle segfaults, exit 139). create_function's refusal is held by test_scalar_function.mojo:test_sqlite_destroys_the_state_exactly_once (O21). test_sqlite.mojo:test_a_finalized_statement_is_never_passed_to_sqlite holds the statement's half, and the entry points it leaves unguarded: with its guards reverted it fails on macOS at clear_bindings's message, a NULL statement there answering SQLITE_MISUSE on Homebrew's 3.53.4 and SQLITE_OK, doing nothing, on Apple's 3.54.0, and at column_name's, which proves the message only, the column count of 0 refusing that read before SQLite sees it either way |
| O30 | Text SQLite reads as a C string arrives whole or is refused: the SQL execute runs and the path a connection opens, by every constructor, are refused before the call when they hold a NUL byte, with SQLITE_MISUSE and the byte's offset, as a function's name is (O21, through the same helper). sqlite3_exec and sqlite3_open_v2 take no length, so a NUL ended the text there and SQLite said nothing: DELETE FROM t, a NUL and WHERE id = 5 emptied the table, and open(tenant + ".db") with a tenant ending in a NUL opened the path without its suffix. prepare takes a length, and refuses what follows a NUL as text it did not compile |
verified | test_sqlite.mojo:test_execute_refuses_sql_holding_a_nul (every PR) — test_file.mojo:test_a_path_holding_a_nul_is_refused_not_cut_short beside it, through open, open_serialized and open_readonly, requiring that nothing is created. With the refusals reverted, the script ran without an error and the path's head was created as a database file |