Exercising the server against real applications
The server run against Django projects nobody here wrote: what broke, what was measured, what changed.
A record. Last run 2026-10-09, against m0serve 1.14.0 on the release
branch at 57e9150, all four applications, six rows, in the newest
section below. The full pass it re-runs was 2026-09-01 and
2026-09-02, against 0.16.0 (bin/m0serve from the tree at c823198),
macOS 26 on an M4, CPython 3.13.6. The plan and the previous records are
below and in the history of this file.
Every application this server had been tested against was written to test it — until the 2026-08-26 pass below, which put three real Django projects in front of it and found five defects. This pass re-runs those three against a server five minor versions on, adds a fourth application nobody here wrote at all, and replaces the pass's phase 5 — six thousand requests sampling RSS — with a driver that asserts bytes: every response compared, status and headers and body digest, against a capture recorded from a reference server (gunicorn, uvicorn, daphne), under five concurrent populations, with logins, and with the server SIGTERM'd and restarted underneath. Three of the previous six defects were silent — a clean status over a short or empty body — and a request loop passes every one.
The four applications
Clean clones served from their own directories, each with its own venv, scratch databases, their own settings unmodified except where the row says.
| app | shape | what it stressed |
|---|---|---|
transcripts |
Django 5, src/ layout (--app-dir src), allauth, a dev auto-login middleware, PDFs rendered per request by a typst subprocess |
the baseline and the CPU + generated-file row; SQLite |
color-separation |
Django 6, numpy/scipy/Pillow, FileResponse downloads, a synchronous separation pipeline |
9.7 MB multipart uploads as a population, 4.7 MB zips and a 9.7 MB original as streams; SQLite; WSGI and ASGI |
textshelf |
Django 6.1, allauth, WhiteNoise, Postgres, four SSE endpoints (one an async generator over psycopg LISTEN/NOTIFY), daphne in production; the Fly dog-food candidate |
the hard case, run the way it will deploy: config.asgi on the executor with a real login, bot detection, rate limiting, and holds abandoned mid-stream |
Wagtail bakerydemo |
Wagtail 8 / Django 6, the CMS's official demo | the admin surface behind a login: 60–130 KB pages, image renditions, a 670 KB static bundle; WSGI and ASGI |
What was found
One server defect, since fixed and gated. And six things that were not the server, each measured rather than assumed.
| # | finding | triage |
|---|---|---|
| 1 | A request body still arriving at SIGTERM holds the drain to its deadline. With 9.7 MB uploads in flight every color-separation drain took exactly 5.0 s; bisected to the uploads population alone, then reproduced directly: a 10 MB POST with 5 MB delivered when SIGTERM lands and the rest sent a second later is never read — the drain loop dispatches writes only — so the client is reset at 5.03 s and the process exits at 5.09 s. gunicorn's graceful timeout keeps reading. The request-side twin of the response-side defect drain_inflight_probe.py fixed. |
fixed — SPEC D9, gated every PR by Smoke test the drain with an upload in flight; scripts/drain_upload_probe.py became the two-sided gate, and the write-up is A request body still arriving at SIGTERM held the drain to its deadline |
| 2 | textshelf's SSE views stall the whole server under WSGI: the LISTEN/NOTIFY async generator is consumed synchronously (Django's own warning), blocks 30 s per abandoned client, and eight abandoned clients hold the whole pool — every other request then times out. Without abandoners the WSGI row is clean; on the executor the same workload is clean with them. |
the documented WSGI limit meeting an app whose generators sleep 30 s — bounded at shutdown as CLAUDE.md says, a full stall in service, which that sentence does not say. A deployment rule, recorded: this app's SSE views run on the executor or as holds, never on pool threads |
| 3 | textshelf at --workers 4 fails with Postgres's FATAL: sorry, too many clients already |
the application: per-thread connections, CONN_MAX_AGE = 10, no pool configured despite psycopg[pool] in its dependencies; four processes exceed a default max_connections of 100. daphne × 4 would do the same |
| 4 | bakerydemo bodies differ between gunicorn and m0serve in a CSS class order and site history's "model/page log entries" label | Wagtail renders both from Python sets, so they follow the interpreter's hash seed: ten seeds split 4/6 on the first, four seeds on m0serve alone split 2/2 on the second. gunicorn's workers agree with each other only because they fork from one master |
| 5 | transcripts' PDF and XLSX differ on every request at identical size | timestamps and per-process font tags: /CreationDate, /ModDate, XMP dates and IDs, typst's six-letter subset-font prefix (constant per process, four workers give four); openpyxl's creation second. All bounded substitutions that pass the driver's blinding lint |
| 6 | RSS oscillates ~100 MB with a ~100 s period on textshelf, and sits at 1.2 GB on color-separation | the applications: daphne under the identical workload oscillates the same (174 ↔ 278 MB against m0serve's 163 ↔ 266); gunicorn's two workers on color-separation sum to 1.5–1.8 GB and rising against m0serve's 1.2–1.3 GB flat |
| 7 | m0serve's process shows a ~490 MB spike in its first 20 s on textshelf, settling to ~170 MB | unexplained; daphne's warm process could not be compared. Watch the first minute after a deploy |
Finding 1 could not have come from apps/: it needs a multi-megabyte
upload in flight at the instant of the signal, which only a population
that uploads continuously produces. Finding 2 could not have come from
the previous pass either, whose textshelf row never abandoned a stream.
Phase 0 — will it even load?
m0serve --doctor ok: true on every entry point: transcript_manager.wsgi
(--app-dir src), halftone_studio.wsgi and .asgi, config.wsgi and
config.asgi, bakerydemo.wsgi and an asgi.py added for it (the
startproject file; bakerydemo ships only wsgi.py). Protocol detection
right every time; WSGI apps got the zero-config pool of 8, ASGI apps the
executor.
Two applications did not load from a clean clone, and the doctor could see
only one of them. transcripts' README installs .[dev], which does not
contain django-watchfiles although settings.py lists it
unconditionally (it is in the uv lockfile's dev group; uv sync works).
color-separation keeps real source under output/ — composite.py,
raster.py, vector.py — and .gitignore excludes output/, so a clone
fails on its first request with No module named 'output'; the doctor
passed, because Django imports its URLconf lazily. Phase 0 answers "does
the callable import", not "does the first request work".
Phase 1 — parity, byte for byte
Each application captured from a reference server and every response of
the soak held to it. The only differences that survived were named
substitutions (findings 4 and 5), a framing choice — uvicorn frames an
undeclared-length body as chunked where m0serve buffers it and sends
Content-Length; the decoded bytes are identical — and the four
security headers runserver's StaticFilesHandler bypasses, on which
gunicorn agrees with m0serve byte for byte.
Phases 3 and 5 — topology, churn, and the soak
Every row: five populations at once (keep-alive bursts at 120 requests per
connection, over the cap of 100; streams read to completion; uploads, slow
views and logins; WebSocket echoes where the app has any; abandoners that
vanish mid-body by FIN and by RST and reuse the slot at once), sampling
RSS, descriptors, threads and the server's own /__metrics. "verified"
is responses byte-identical to the reference capture.
| app | mode | seconds | verified | failures | churn | RSS |
|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 41,693 | 0 | SIGTERM ×2, drains 0.14 s | 123.2 → 124.2 MB |
| transcripts | WSGI, --workers 4 --blocking-threads 4 |
90 | 38,145 | 0 | SIGTERM ×1, 0.14 s, no orphans | 4 workers, 419 → 421 MB summed |
| color-separation | WSGI, pool 8, 370 uploads (185 of 9.7 MB) | 150 | 61,327 | 0 | SIGTERM ×2, 5.1 s each (finding 1) | 1.24 → 1.28 GB (finding 6) |
| color-separation | ASGI executor vs uvicorn, 250 uploads | 90 | 18,274 | 0 | — | 1.32 → 1.31 GB |
| textshelf | ASGI executor vs daphne, 3 sessions | 90 | 32,843 | 0 | — | settles at ~170 MB (finding 7) |
| textshelf | ASGI executor vs daphne | 240 | 81,819 | 0 | — | 163 ↔ 266 MB sawtooth (finding 6) |
| textshelf | ASGI executor, churn | 100 | 30,933 | 0 | SIGTERM ×2, 5.0 s each: held streams never end, the drain waits its budget by design | 179 → 162 MB |
| textshelf | WSGI, pool 8, abandoners on | 90 | 1,689 | 13 timeouts | — | stalled (finding 2) |
| textshelf | WSGI, pool 8, no abandoners | 60 | 23,883 | 0 | — | clean |
| textshelf | ASGI, --workers 4 |
90 | 61,108 | 28,078 | drain 5.05 s | Postgres connection limit (finding 3) |
| bakerydemo | WSGI, pool 8, 4 sessions + 402 logins | 240 | 28,781 | 0 | — | 197 → 207 → 196 MB, flat |
| bakerydemo | WSGI, churn | 75 | 8,730 | 0 | SIGTERM ×2, 0.13 s and 0.26 s | — |
Descriptors and threads were flat in every clean row. --threads was
not run: none of the four dependency trees builds on free-threaded CPython
(psycopg-binary ships no wheel; the record above says the same).
What this changes about the guards
- The driver itself (
scripts/soak.py,poe soak-apps,poe soak-selftest): its comparator is a pure function with a self-test that found the driver's own first hole — a substitution greedy enough to absorb a truncation blinds the instrument — so a capture now refuses any route its patterns blind and carries a fingerprint of the rules it was recorded under. Sabotaging a manifest's byte count makes the task exit 1 naming the failure. scripts/drain_upload_probe.py, finding 1's reproducer and the gate the fix will land with, two-sided like its sibling: the upload must be answered whole, and the process must exit inside 3 s of the signal.- Four manifests under
scripts/soak_manifests/are the re-runnable record of each application's shape, including every substitution and the reason for it.
FastHTML's and FastAPI's own examples, beside uvicorn — 2026-09-22
A separate pass, not the soak above. FastHTML's official examples — the
examples/ of AnswerDotAI's fasthtml repository and the
fasthtml-example repository, 37 applications — and FastAPI's documented
chat room, served side by side under bin/m0serve from main at
ef2811e and under uvicorn 0.53.0: python-fasthtml 0.14.14, Starlette
1.6.0, CPython 3.13.6, macOS 27 on an M4. Each application ran from its own
copy per server, so no SQLite file or session key was shared; the same
scripted scenario went to both; the responses were compared with only the
port normalised, and both logs were read. The gates below are what the pass
left in the tree.
Most of what the FastHTML tutorial teaches matched: sessions and
Beforeware logins, fastlite writes, HTMX out-of-band swaps, toasts,
cookies, multipart uploads, FileResponse with ranges, gzip, streamed and
SSE responses under 40 concurrent clients, disconnect detection, lifespan,
WebSockets, and live=True under --reload. Under --workers 2 both
servers lost a worker to fastlite's schema race at import; m0serve's
supervisor respawned it, and uvicorn carried on with one.
What did not match, most harmful first:
| found | SPEC | now |
|---|---|---|
a send kept for a client that had left reached the next client on its recycled slot: FastAPI's documented chat room delivered a departed client's messages to a stranger |
L20 | fixed |
| a disconnect hook's sends to the sockets still connected were refused | L20 | fixed |
a WebSocket disconnect cancelled the task, so an except WebSocketDisconnect: cleanup never ran |
L21 | fixed |
a response waited for its BackgroundTask |
L22 | fixed |
an application error's own 500 — the debug=True page included — was replaced, and the traceback lost |
L23 | fixed |
| a WebSocket whose application raised was closed with 1000 | L24 | fixed |
sockets survive exec: a shell the application started held a client connection open |
G16 | fixed |
application/octet-stream on responses sent without a type; a HEAD's Content-Length rewritten to 0; Content-Length: 0 on 204 and 304 |
A21, K12 | fixed |
a chunked request's scope carries both transfer-encoding and content-length; a module that calls asyncio.create_task at import cannot load |
L25, L26 | fixed |
Three more failures were FastHTML's own and identical under both servers:
setup_ws reads scope.client as an attribute of a dict, the terminal
example's disconnect hook takes an argument FastHTML does not pass, and
fastlite's db.create races across workers at import.
Re-run on the response heads, 2026-09-23 (A21, K12, L27; python-fasthtml
0.14.13, Starlette 1.6.0, uvicorn 0.53.0). The probe application's heads,
read beside uvicorn: FastHTML's default 404, a RedirectResponse, a 204, a
304, HEAD of a FileResponse and of a 5 MB page, and HEAD of a sync and
an async StreamingResponse, each HEAD followed by a second of listening
for bytes after its head. All eight now match on status, Content-Type,
Content-Length and Transfer-Encoding, and neither server sends a byte
after a HEAD's head. Before, the 404, the redirect, the 204 and the 304 each
carried application/octet-stream, the 204 and 304 a content-length: 0,
and the FileResponse HEAD content-length: 0 where uvicorn sent 3145735.
The streamed HEAD was found while fixing the rest, on apps/asgi_bare: all
10,000 bytes of a 10,000-byte stream followed its head (L27).
Re-run of every scenario, 2026-09-23 (after L25 and L26; the same
versions). Sixteen scenarios, the heads probe and --workers 2, m0serve
beside uvicorn. A GET's scope and a sized POST's now carry exactly the
headers uvicorn's do. Before, m0serve's GET added a content-length: 0.
A chunked POST's scope differs by design, content-length: 7 where
uvicorn passes transfer-encoding: chunked, and so FastHTML's multipart
check still accepts a chunked upload under m0serve that it refuses under
uvicorn. 00_game_of_life, unmodified, now exits 1 naming what its module
did and the lifespan fix, where it printed only no running event loop.
Every other difference is the harness's own:
- file timestamps;
- the order of a
SetStarlette joins intoAllow; - traceback paths;
m0in the lifespan state;- the
%2Frecorded above.
FastHTML's own three failures are unchanged, and identical under both servers.
In production — 2026-09-12, textshelf's streams on the hold mount (m0serve 1.2.0)
Not a soak: a production record, the first use of the grant-verified hold
mount (SPEC I21) by an application nobody wrote for this server. textshelf
(Django 6.1, allauth, Postgres, one Fly machine, --realtime --blocking-threads 8) had run its five SSE streams as M0-Hold views
since its cutover to m0serve; today it moved them onto
--mount /_m0/rt=hold. Each view keeps its authentication and membership
check and, asked with ?grant=1, signs its decision into the stream URL
(m0serve.grant.stream_url, bound to the session cookie, an hour's TTL)
instead of holding; the page renders that URL, and a Mojo pool thread
verifies the grant and holds with no Django request per connection. Its
side of the change is textshelf PR #570 (textshelf/utils/realtime.py,
a template tag, one renewal script, the four process commands), with the
M0_GRANT_KEY secret staged on each app before the command that needs it
ran.
Local, under the tree's 1.2.0 binary, before the deploy. The view
issued a grant into /_m0/rt bound to the session; production_stream_check.py
followed it — 200, text/event-stream, both M0- headers stripped, : open
as the mount's head, a heartbeat at 15 s; a notifications_read published
from inside the process (mark-all-read) arrived on the mount-held stream;
and the mount refused a missing cookie (invalid, no session cookie), a
different session's cookie (invalid, session mismatch) and an expired
grant (expired), each by name.
Staging (textshelf-staging.fly.dev), after the deploy. The two
remote stream smokes passed through the suite's own Playwright login (the
view holds; the URL the view issues is a held stream). The production check
on the grant route: status 200 after 0.1s, held by the mount, : open,
heartbeat at 15s, ok — through Fly's proxy, with the same Cache-Control: no-cache and X-Accel-Buffering: no the view-held stream carries. A
malformed grant is refused as {"error":"invalid","reason":"malformed"}.
Production (textshelf.com). Promoted through textshelf's own workflow (staging validated, then
promote-to-production.yml, then its post-deploy smoke, all green). The
machine runs the 1.2.0 wheel's binary as
m0serve --mount /=config.wsgi --mount /_m0/rt=hold --realtime --blocking-threads 8 ... with M0_GRANT_KEY in its environment; the
mount refuses a malformed grant as {"error":"invalid","reason":"malformed"},
the view answers 401 without a session, and /_m0/health answers.
Then the check with a real session, run by the owner from a signed-in
browser the same day:
GET https://textshelf.com/notifications/stream/?grant=1
status 200
stream url /_m0/rt/stream (the hold mount)
content-type 'text/event-stream'
hold headers stripped, so m0serve took the hold
`: open` from the mount
heartbeat at 15s: : heartbeat
ok stream is held
The view issued the grant, the mount took the hold, and the heartbeat only m0serve writes arrived on schedule through Fly's proxy: a stream held for a real user of a real application on a Mojo thread that never touched the interpreter.
Two things the pass found, neither in the server. The check script
read a stream's head and then nothing against staging over TLS, on the
view path as much as the mount: a fresh iter_content per frame marked the
response consumed, and a single generator reading the same stream
heartbeated on time — textshelf PR #571. And the first staging check's
24 s heartbeat window started before the connect, which a machine waking
from auto-stop can consume; the window now starts at the response.
What this does and does not say. The mount holds real streams for a
real application behind a real proxy, refuses what it should by name, and
renews through the view when a grant expires — the browser side of that,
held_stream.js, is exercised by every page that opens a stream and by no
gate yet. The application layer's own soak was unchanged by it (NOT MET
then; recorded below on 2026-10-02): this is the server holding for textshelf, not textshelf on Views
and Fragment.
Re-soak — 2026-10-09, against 1.14.0 on the release branch at 57e9150, all four applications
The staleness rule, ahead of the release. The 1.11.0 pass is two minors behind 1.13.0, the limit, so 1.14.0 would have read it STALE. What moved under these applications since: the review of the fork's HTTP core, shipped in 1.12.1 and 1.13.0, changed where a request head ends, how pipelined and half-closed connections are answered, and how a stream open at a SIGTERM ends, which the rows' keep-alive bursts, abandoners and restarts all cross; and 1.14.0 closes a stream whose client has gone in the pass whose send failed, the path textshelf's abandoners take on its stream route. The access log is off in every row, as before.
/tmp/soak/ did not survive a reboot, so every checkout was made fresh:
transcripts at 00dcb7a and bakerydemo at c8f8255, the 2026-10-06
commits; textshelf at 7479ee7, one commit on that touches only its editor
settings, against its scratch database textshelf_soak, whose soak user
had survived; and color-separation at 0f81a9c, thirteen commits on from
65c5445, with Django 6.1.1 and Pillow 12.3.0, its output/ package now
tracked and m0serve now a dependency. The seeded fixtures and the processed
archive came out at the sizes the manifests pin, and no manifest needed a
change. transcripts' fresh venv had no gunicorn of its own, and without one
its reference capture would have run the system Python's; gunicorn 26.2.0
was installed into the venv first. Every reference capture was re-recorded
the same evening. bin/m0serve was built from the release branch and
reports m0serve 1.14.0, and textshelf's venv, which imports
m0serve.m0pub, carries the 1.14.0 wheel. CPython 3.13.6 (3.12.11 for
color-separation and bakerydemo), macOS 27 on an M4. Raw driver outputs and
the scripts that drove it are in bench/soak/2026-10-09/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 26,041 | 0 | SIGTERM ×2, drains 143 ms max (window 746 ms) | 119.7 → 124.0 MB | 89 → 88 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions, 306 logins | 180 | 20,559 | 0 | SIGTERM ×2, drains 185 ms max (window 1141 ms) | 177.5 → 184.1 MB | 83 → 84 / 13 → 13 |
| color-separation | WSGI, pool 8, 286 uploads | 120 | 41,955 | 0 | SIGTERM ×1, drains 130 ms max (window 929 ms) | 0.98 → 1.21 GB (finding 6) | 150 → 146 / 17 → 18 |
| color-separation | ASGI executor vs uvicorn, 214 uploads | 90 | 16,927 | 0 | — | 1.06 → 1.37 GB (finding 6) | 122 → 130 / 24 → 32 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 74,016 | 0 | SIGTERM ×1, drains 74 ms max (window 1196 ms) | 173.4 → 182.8 MB | 90 → 78 / 17 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 27,435 | 0 | — | 490.7 → 190.6 MB (the startup transient) | 100 → 99 / 13 → 13 |
206,933 responses verified byte for byte, zero failures. No row's active connections rose above the nine its populations open, free slots mirroring them; the last sample is taken with traffic still in flight, as on 2026-10-06. Every upload landed: 286 of 286 in the WSGI row, whose restart cut none this time, and 214 of 214 under the executor.
The rows ran while a remote session's node jobs held a core at times, as
the 2026-10-06 rows ran beside another session's, and the counts moved
against that pass's both ways: bakerydemo 20,559 against 15,306 (306
logins against 199), textshelf's WSGI row 27,435 against 22,221,
transcripts 26,041 against 30,159. The rows assert bytes rather than rate,
so the figures are recorded and not explained.
Nothing was found on the wire. The one application that moved, color-separation, moved through its own dependency upgrades, and the server answered it byte for byte as gunicorn and uvicorn did.
Re-soak — 2026-10-06, against 1.11.0 on the release branch at 3e64f2a, all four applications
The staleness rule, ahead of the release. The 1.8.0 pass is two
minors behind 1.10.0, the limit, so 1.11.0 would have read it STALE. What
moved under these applications since: an ASGI application's lifespan
startup is now waited for before the ready banner, and a failed one exits
1 (L33), which is the path both ASGI rows start on and the textshelf
row's restart takes; the loop counts a response's body as sent, file
bodies included, and --metrics, which every row sets, counts file
bodies with it (F2); and --max-body, which the color-separation rows
set to 64m, gained an environment form and a first-refusal notice
beside it. The access log, whose line changed shape (F20, F21), is off
in every row, as it was before.
The 2026-10-02 checkouts under /tmp/soak/ had survived and were used
as they were: transcripts at 00dcb7a, color-separation at 65c5445,
textshelf at 7981657 against its scratch database textshelf_soak,
bakerydemo at c8f8255. Every reference capture was re-recorded first,
and no manifest needed a change. bin/m0serve was rebuilt from the
release branch and reports m0serve 1.11.0, and textshelf's venv, which
imports m0serve.m0pub, carries the 1.11.0 wheel. CPython 3.13.6
(3.12.11 for color-separation and bakerydemo), macOS 27 on an M4. Raw
driver outputs and the scripts that drove it are in
bench/soak/2026-10-06/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 30,159 | 0 | SIGTERM ×2, drains 138 ms max (window 721 ms) | 119.6 → 124.2 MB | 86 → 88 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions, 199 logins | 180 | 15,306 | 0 | SIGTERM ×2, drains 143 ms max (window 2055 ms) | 153.1 → 148.8 MB | 86 → 85 / 13 → 13 |
| color-separation | WSGI, pool 8, 303 uploads | 120 | 45,643 | 0 | SIGTERM ×1, drains 195 ms max (window 951 ms) | 1.07 → 1.35 GB (finding 6) | 148 → 146 / 20 → 18 |
| color-separation | ASGI executor vs uvicorn, 246 uploads | 90 | 17,960 | 0 | — | 1.12 → 1.41 GB (finding 6) | 124 → 122 / 28 → 32 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 75,605 | 0 | SIGTERM ×1, drains 37 ms max (window 1011 ms) | 172.3 → 206.8 MB | 98 → 85 / 16 → 17 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 22,221 | 0 | — | 492.1 → 163.7 MB (the startup transient) | 98 → 96 / 13 → 13 |
206,894 responses verified byte for byte, zero failures. Each row ends with its active connections within one of where they began and never above the nine its populations open, free slots mirroring them: the last sample is taken with traffic still in flight, as on 2026-10-02. One upload of 303 was cut by the WSGI row's restart and counted as tolerated, as one of 256 was then.
bakerydemo verified 15,306 against 21,490 in the same 180 s, signed in
199 times against 311, and its longest restart window was 2,055 ms
against 1,177. The machine was not quiet: the rows began minutes after a
stage of sabotage builds, while Spotlight was indexing the new release
worktree, and another session's node process held a core. The row
asserts bytes rather than rate, so the figure is recorded and not
explained.
Nothing was found on the wire, and nothing moved with the applications: the four checkouts are the last pass's, and so is every manifest.
Re-soak — 2026-10-02, against 1.8.0 on main at d624ae7, all four applications
Run ahead of the staleness rule this time. At 1.8.0 the 1.6.0 pass is
two minors behind a limit of two, so the milestone still read MET, and
1.9.0 would have read it STALE. Between the two passes the fork under
every row was reviewed and cut: its typed socket errors became one
SysError, the blocking accept loop and about 870 lines nothing used were
removed, and 1.8.0's changelog lists the request-path fixes that came
with it — a POST's leftover timer, a keep-alive request's second
registration, a listener closed once by its drain, response heads that
refuse CR and LF (G1, G2). So this pass asks the old question of a server
whose loop was rewritten in place.
Fresh clones under /tmp/soak/ (the 2026-09-24 checkouts had not
survived): transcripts at 00dcb7a, color-separation at 65c5445 with
its gitignored output/ source copied in, textshelf at 7981657 against
its scratch database textshelf_soak, bakerydemo at c8f8255. Every
reference capture was re-recorded first. bin/m0serve was rebuilt from
main and reports m0serve 1.8.0; CPython 3.13.6 (3.12.11 for
color-separation and bakerydemo), macOS 27 on an M4. Raw driver outputs
and the scripts that drove it are in bench/soak/2026-10-02/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 26,563 | 0 | SIGTERM ×2, drains 179 ms max (window 1481 ms) | 119.6 → 124.8 MB | 91 → 89 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions, 311 logins | 180 | 21,490 | 0 | SIGTERM ×2, drains 244 ms max (window 1177 ms) | 177.7 → 186.0 MB | 84 → 83 / 13 → 13 |
| color-separation | WSGI, pool 8, 256 uploads | 120 | 37,857 | 0 | SIGTERM ×1, drains 142 ms max (window 900 ms) | 1.01 → 1.33 GB (finding 6) | 151 → 158 / 19 → 18 |
| color-separation | ASGI executor vs uvicorn, 244 uploads | 90 | 17,811 | 0 | — | 1.11 → 1.48 GB (finding 6) | 134 → 120 / 22 → 33 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 81,019 | 0 | SIGTERM ×1, drains 46 ms max (window 825 ms) | 177.0 → 182.9 MB | 111 → 106 / 16 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 26,921 | 0 | — | 491.3 → 192.5 MB (the startup transient) | 99 → 98 / 13 → 13 |
211,661 responses verified byte for byte, zero failures, and every row ends with its active connections and free slots where they began. transcripts verified a third fewer than on 2026-09-24 in the same 150 s (26,563 against 40,117), back at the 2026-09-13 figure (29,989); the build that preceded the rows ran on the same machine minutes earlier, and the row asserts bytes rather than rate, so the figure is recorded and not explained.
Nothing was found on the wire. Three things moved with the applications or the calendar, and the first cost a run of the two textshelf rows:
- textshelf at
7981657renders a masked CSRF token into every signed-in page'shx-headersattribute, new bytes on each request. The first run of its rows failed 8,273 comparisons on/notifications/,/users/~update/and/feeds/tokens/, each a body of the capture's length with one differing token. The manifest gained abody_subrule for"X-CSRFToken": "…", daphne was re-captured under it (the rules fingerprint refuses the old capture), and both rows were run again; the table carries the second run. - textshelf's
components.cssis 103,602 bytes at7981657, where the manifest had 121,037. WhiteNoise under daphne serves the new size. - color-separation files media by month, and the manifest names
2026/09. The checkout'smedia/uploads/2026/09andmedia/outputs/2026/09are symbolic links to10for this pass, so the manifest's two media routes read the files job 1 produced today.
Re-soak — 2026-09-24, against 1.6.0 on the release branch at 9b051fd, all four applications
The staleness rule again. At 1.6.0 the 1.3.0 pass is three minors
behind a limit of two. Between the two passes the request path these
applications take did change: a parsed request now carries only the
headers its client sent, so a GET reaches Django with no CONTENT_LENGTH
(L25); the ASGI executor's slot, disconnect and background-task rules
moved for Starlette's sake (L20–L28), and textshelf and color-separation
run on it; every descriptor the server creates is close-on-exec (G16); and
a raw @ in a target is data (A22). So this pass asks the old question of
a server whose request path shifted, not merely grew.
Fresh clones under /tmp/soak/ (the 2026-09-13 checkouts had not
survived): transcripts at 00dcb7a, color-separation at 65c5445 with
its gitignored output/ source copied in, textshelf at 1ef2bfa
against its scratch database textshelf_soak, bakerydemo at c8f8255
with the asgi.py this record added in 2026-09. Every reference capture
was re-recorded first. One manifest pin moved with an application rather
than the server: textshelf's components.css is 121,037 bytes at
1ef2bfa, where the manifest had 125,113, and WhiteNoise under daphne
serves exactly the new size. bin/m0serve reports m0serve 1.6.0;
CPython 3.13.6 (3.12.11 for color-separation and bakerydemo, which pin
it), macOS 27 on an M4. Raw driver outputs and the scripts that drove it
are in bench/soak/2026-09-24/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 40,117 | 0 | SIGTERM ×2, drains 138 ms max (window 710 ms) | 119.1 → 123.8 MB | 86 → 85 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions, 316 logins | 180 | 21,424 | 0 | SIGTERM ×2, drains 202 ms max (window 1189 ms) | 180.6 → 188.2 MB | 84 → 85 / 13 → 13 |
| color-separation | WSGI, pool 8, 310 uploads | 120 | 42,163 | 0 | SIGTERM ×1, drains 139 ms max (window 901 ms) | 1.02 → 1.34 GB (finding 6) | 149 → 147 / 17 → 19 |
| color-separation | ASGI executor vs uvicorn, 231 uploads | 90 | 17,702 | 0 | — | 1.09 → 1.50 GB (finding 6) | 130 → 129 / 25 → 31 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 72,458 | 0 | SIGTERM ×1, drains 85 ms max (window 860 ms) | 180.8 → 187.8 MB | 116 → 92 / 16 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 22,753 | 0 | — | 495.9 → 196.3 MB (the startup transient) | 103 → 101 / 13 → 13 |
216,617 responses verified byte for byte, zero failures, and every row ends with its active connections and free slots where they began. transcripts verified a third more than on 2026-09-13 in the same 150 s (40,117 against 29,989). The rows otherwise read as that pass did: color-separation's two rows carry the application's own RSS (finding 6), its ASGI row's threads climb as asgiref's pool fills (25 → 31, max 36), and textshelf's WSGI row shows the startup transient falling away.
Nothing new was found on the wire. The run found three problems in the pre-release tooling and none in the server:
sabotage-blobsreported a rule no gate guards. Its anchor still matched an import line that the host's command line had changed (cda3cd0), and it now matches the new one.- The browser gates failed at launch. The Playwright that
uv run --withresolved wanted a Chromium the cache lacked, andplaywright install chromiumfixed it. - The first
bench-linux-conclusionsandautobahnruns came back contaminated, one with its spreads up to 23 % and the other with a thin section, while the host was busy. Both were discarded and rerun with the machine quiet.
Re-soak — 2026-09-13, against 1.3.0 on the release branch at 8c43979, all four applications
The staleness rule is why this pass exists, and this time the code under
it moved a great deal. At 1.3.0 the 1.0.0 pass is three minors behind a
limit of two, so poe milestones prints the soak STALE and the 1.0
milestone stops being MET. Between the two passes the tree gained
m0-postgres and a Postgres NOTIFY door onto the broadcast bus (O6–O16,
I22), a Mojo mount an application supplies as a module (N14), sessions and
CSRF behind a login (N13), the Datastar form (N12), and a fix for a
splitter that a request could trap (G14). Only the last is on the request
path of these four applications; the rest is reached by configuration none
of them sets. So this pass asks the same question the others did — does
somebody else's Django project still work, byte for byte, under churn —
against a server whose surface grew rather than shifted.
The checkouts under /tmp/soak/ survived this time, which is unusual. Every
reference capture was re-recorded first regardless: they dated from
2026-09-10 and the rule is to re-baseline before each pass, because the
soak's own traffic mutates the applications' databases and a stale capture
fails on CONTENT, which reads as a server defect and is not one. textshelf
ran against its scratch database textshelf_soak, never the adjacent dev
database — the mistake a previous pass made, which put soak data into real
developer data. bin/m0serve was built from the release branch and reports
m0serve 1.3.0; CPython 3.13.6, macOS 26 on an M4. Raw driver outputs and
the scripts that drove it are in bench/soak/2026-09-13/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 29,989 | 0 | SIGTERM ×2, drains 136 ms max (window 685 ms) | 120.4 → 124.8 MB | 89 → 84 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions | 180 | 21,737 | 0 | SIGTERM ×2, drains 150 ms max (window 1219 ms) | 186.8 → 194.3 MB | 85 → 84 / 13 → 13 |
| color-separation | WSGI, pool 8, uploads | 120 | 46,934 | 0 | SIGTERM ×1, drains 148 ms max (window 833 ms) | 985.8 MB → 1.43 GB (finding 6) | 141 → 156 / 18 → 18 |
| color-separation | ASGI executor vs uvicorn | 90 | 18,701 | 0 | — | 1.21 → 1.51 GB (finding 6) | 132 → 133 / 23 → 32 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 72,311 | 0 | SIGTERM ×1, drains 82 ms max (window 1103 ms) | 174.6 → 169.8 MB | 103 → 87 / 16 → 17 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 24,484 | 0 | — | 491.9 → 191.8 MB (the startup transient) | 99 → 98 / 13 → 13 |
214,156 responses verified byte for byte, zero failures, every sample
reporting failures=0 throughout. Descriptors and threads flat in every row
but one: color-separation's ASGI row runs 23 → 32 threads (max 33), which is
asgiref's thread pool filling under the executor, as the 2026-09-10 record
says of the same row. The churn column reports the DRAIN, with the whole
restart window beside it — 2026-09-10 reported drains alone, so reading this
pass's window (1219 ms) against that pass's drain (0.26 s) would look like a
regression and is not one: the drains here are 82–150 ms. The two color-separation rows carry the RSS the record has called the
application's since 2026-09-10 (finding 6): gunicorn's two workers sum
1.5–1.8 GB on the same workload. textshelf's WSGI row again shows the
startup transient falling away rather than growth.
Nothing new was found on the wire. The defect this release run did turn
up was in the pre-release TOOLING rather than the server: the Linux
container's build list never gained postgres after m0-wsgi began
importing m0_postgres for --pg-listen, so build-wsgi could not build
in the container at all and stress-pool failed before its first round.
CI cannot see it — there is no such container there — which is the reason
that tier exists. Fixed in the FOUR places that name the list: scripts/probes/linux_sync.sh,
the stress-pool task, scripts/bench_linux_conclusions.py (which would
have hit it next), and scripts/probes/linux_setup.sh — the last found in
review, and the one that matters most, because it is what CREATES the
container. Without it a fresh m0lin could not be built at all; this run
escaped only because its container already existed.
Re-soak — 2026-09-10, against 1.0.0 as merged at a4f0aa5, all four applications
The version number is why this pass exists, and almost nothing else
is. The 1.0.0 release bumped pyproject.toml and left the soak record
naming 0.19.0, so poe milestones printed the soak STALE from the merge
commit onward — the milestone's staleness rule allows two minors of lag and
scores a major bump far past it. The release commit's own message says the
soak is current against this version; it was true when written against
0.19.0 and false the moment the bump landed in the same commit. This pass
is what makes it true.
The code under it barely moved. Between the 0.19.0 pass and this one,
exactly one commit touches the request path: bf212a3, which splits the
graceful drain into begin, step and finish so the loop inversion can drive
it one pass at a time. The blocking composition every default topology runs
is unchanged, and the stepped path is reachable only under M0_INVERTED=1.
Everything else since is --doctor reporting (usable_cpus,
topology.loop), the FastAPI gate, the row-status ratchet and the Linux
benchmark work — none of it on the wire. So the interesting half of this
pass is the three churn rows, which SIGTERM the server mid-load and are the
only place the corpus exercises that refactor.
Fresh clones under /tmp/soak/ (the 2026-09-07 checkouts had not survived,
as always), each with its own venv on PATH, bin/m0serve rebuilt from
a4f0aa5, CPython 3.13.6, macOS 26 on an M4. Same driver, same five
populations, same durations and churn cadences; every reference capture
re-recorded first. Raw driver outputs and the scripts that drove it are in
bench/soak/2026-09-10/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 29,546 | 0 | SIGTERM ×2, drains 0.04 / 0.14 s | 120.7 → 124.4 MB | 85 → 88 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions + 308 logins | 180 | 21,672 | 0 | SIGTERM ×2, 0.08 / 0.26 s | 185.0 → 190.6 MB | 80 → 85 / 13 → 13 |
| color-separation | WSGI, pool 8, 343 uploads | 120 | 47,227 | 0 | SIGTERM ×1, 0.08 s | 1.05 → 1.36 GB | 144 → 151 / 18 → 19 |
| color-separation | ASGI executor vs uvicorn, 268 uploads | 90 | 18,823 | 0 | — | 1.24 → 1.57 GB | 129 → 120 / 26 → 30 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 73,182 | 0 | SIGTERM ×1, 0.09 s | 173.1 → 181.1 MB | 101 → 125 / 17 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 24,764 | 0 | — | 493.3 → 191.1 MB (the startup transient) | 98 → 100 / 13 → 13 |
215,214 requests verified byte for byte against the reference captures, zero failures in every sample of every row, every slot returned (the free-slot count back at its starting value at every sample), threads flat, and 5,682 abandoners across the run absorbed, half closing with FIN and half with RST. No server defect. The six drains all finished inside 0.26 s, which is the observation this pass was really after: the split left the composition alone, and a SIGTERM under load still ends in a quarter of a second.
Nothing moved against the 2026-09-07 table that is not already explained there. The color-separation gigabytes are its image renditions; textshelf's WSGI row again opens on the ~490 MB startup transient that finding 7 describes and settles inside the minute; the executor rows again carry more threads than the pooled ones, which is asgiref's thread pool for that app's synchronous views. One count differs by one on the color-separation churn row — 343 uploads, 342 landed — and it is the same shape 2026-09-07 recorded (344 and 343): the POST is counted when it answers, the follow-up GET of the page it redirects to is what marks it landed, and a restart between the two is a tolerated connection error rather than a failure.
Three things about the corpus changed, all setup rather than server.
- color-separation's fixtures are seeded now. Its two images are noise
PNGs, generated fresh each pass and therefore a different size each pass,
which is what forced the manifest's
bytespins to be re-measured by hand — the third of the three traps this record already lists.scripts/soak_fixtures.pygenerates them from seed 20260910; a second run reproduces both files byte for byte, so the pins are now a property of the manifest rather than of the run. They moved once more in the process, this seed's zip being 7.7 MB where the unseeded one was 9.6. - The rows script no longer carries a database password. 2026-09-07's
copy had textshelf's
DATABASE_URLwith the secret redacted by hand before it was committed, which is a step that only has to be forgotten once. libpq reads~/.pgpassfor thepostgresrole, so the URL inbench/soak/2026-09-10/soak_rows.shnames no password at all. - textshelf now imports
m0serve.m0pubat startup, so the subject cannot be served until the wheel is installed in its venv. This pass built the tree's own 1.0.0 wheel and installed that, which makes the application's realtime imports part of what the soak covers rather than something stubbed around.
What this pass did not do, unchanged from the records above:
--threads (none of the four dependency trees builds on free-threaded
CPython), NiceGUI, the proxy and worker shapes. It also did not exercise
the stepped drain itself, the one thing that changed on the request path:
that path needs M0_INVERTED=1, which no row here sets, and its gate is
smoke-asgi's inverted arm on every pull request.
Re-soak — 2026-09-07, against 0.19.0 as merged at abf2016, all four applications
Three minor-version's worth of change landed between the pass above and
this one, and two of them are on the request path of every keep-alive
connection: the elastic pool (0.19.0 serves a trivial view on one handler
thread until a job has waited, and wakes each thread by name on a channel
of its own) and the keep-alive request cap, 1000 from 100, with
--max-keepalive-requests to set it — the cap having turned out to be the
whole of the fast-route tail the benchmark page had conceded to Granian
(notes/pool-tail.md). So the corpus was run again on
the release commit: fresh clones under /tmp/soak/ (the 2026-09-01
checkouts had not survived), each with its own venv on PATH, bin/m0serve
built from abf2016, CPython 3.13.6 (the GIL build a pip install
gets), macOS 26 on an M4. Same driver, same five populations, same
durations and churn cadences; every reference capture re-recorded first,
as the rule from the pass above says. Raw driver outputs and the three
scripts that drove it are in bench/soak/2026-09-07/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 30,271 | 0 | SIGTERM ×2, drains 0.07 / 0.08 s | 120.0 → 124.2 MB | 89 → 85 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions + 308 logins | 180 | 21,847 | 0 | SIGTERM ×2, 0.14 s | 183.8 → 193.5 MB | 82 → 86 / 13 → 13 |
| color-separation | WSGI, pool 8, 343 uploads | 120 | 47,761 | 0 | SIGTERM ×1, 0.18 s | 1.06 → 1.35 GB | 144 → 143 / 18 → 18 |
| color-separation | ASGI executor vs uvicorn, 270 uploads | 90 | 19,022 | 0 | — | 1.17 → 1.63 GB | 130 → 130 / 29 → 32 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 73,250 | 0 | SIGTERM ×1, 0.12 s | 176.8 → 185.3 MB | 113 → 120 / 17 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 24,755 | 0 | — | 493.1 → 193.9 MB (the startup transient) | 99 → 99 / 13 → 13 |
Every row: zero failures, every slot returned (the free-slot count back at
its starting 1,015 at every sample), threads flat, every drain inside a
fifth of a second, and 560–1,550 abandoners per run (half FIN, half RST)
absorbed. No server defect. Two shapes moved against the 2026-09-04
table and neither is growth: the pooled rows hold 16–20 more descriptors
from their first sample (the elastic pool parks each of the eight handler
threads on a SOCK_DGRAM pair of its own — two descriptors per thread,
allocated at start, flat for the run), and the executor rows carry more
threads than before (29–33 on color-separation against 20), which is
asgiref's thread pool for that app's synchronous views under a larger
upload fixture, the application behaviour the 0.16.0 record already named.
The color-separation gigabytes are its image renditions, as before, and
textshelf's WSGI row again opens on the ~490 MB startup transient the
record's finding 7 describes, settling inside the minute.
Three rows failed first, and none was the server. Recorded because each cost time and the next pass should not pay it again:
- transcripts and bakerydemo, "never became ready" — the runner had
put
<checkout>/binrather than<checkout>/.venv/binon PATH, som0serveembedded the system interpreter, which has Django and none of the apps' packages. The driver's log named the missing module on the first line of the traceback. - transcripts and textshelf came up on free-threaded 3.14t — the
release worktree's benchmark swap had left that interpreter installed
and
uv syncpreferred it, so textshelf'spsycopghad no wheel to install. Both venvs were rebuilt with--python 3.13; color-separation pins 3.12 itself and kept it. - color-separation 3,062 and textshelf 22,752 "failed" stream reads,
each one shape: the manifests pin a
bytesvalue for their large streams, and the pins were the 2026-09-01 fixtures' — abig.pngtwelve bytes larger than the regenerated one, job 1 processed from the big image (this pass had made it from the small one; rebuilt, and the zip is 9.6 MB where a photograph's was 4.7 — the fixture is noise, which does not compress), and acomponents.cssthat textshelf has since grown by 187 bytes. The pins now carry the fixture sizes with a comment saying so. The rule the pass above added — re-baseline before every pass — covers the captures; a manifest pin is a second expectation and rots the same way, so re-pin with the fixtures.
What this pass did not do, unchanged from the records above:
--threads (none of the four dependency trees builds on free-threaded
CPython), NiceGUI, the proxy and worker shapes.
Re-soak — 2026-09-04, against 0.17.1 as merged at 0766272, all four applications
The event loop stopped holding a thread state while it serves (PR #229,
docs/notes/detached-loop.md): the default runtime shape of every pooled
and executor deployment changed, and the smokes that gated it were all
written here. So the corpus was run again the same night — the four
checkouts of 2026-09-01 under /tmp/soak/, each with its own venv on PATH,
bin/m0serve built from main at 0766272, CPython 3.13.6 (the GIL build a
pip install gets), macOS 26 on an M4. Same driver, same five populations,
same reference captures except where noted. Raw driver outputs are in
bench/soak/2026-09-04/.
| app | mode | seconds | verified | failures | churn | RSS | fds / threads |
|---|---|---|---|---|---|---|---|
| transcripts | WSGI, pool 8 | 150 | 41,749 | 0 | SIGTERM ×2, drains 0.14 s | 120.2 → 124.4 MB | 69 → 72 / 13 → 13 |
| bakerydemo | WSGI, pool 8, 4 sessions + 300 logins | 180 | 21,980 | 0 | SIGTERM ×2, 0.14 s | 186.8 → 198.1 MB | 69 → 69 / 13 → 13 |
| color-separation | WSGI, pool 8, 361 uploads | 120 | 53,394 | 0 | SIGTERM ×1, 0.08 s | 0.95 → 1.30 GB | 136 → 142 / 17 → 17 |
| color-separation | ASGI executor vs uvicorn, 302 uploads | 90 | 20,980 | 0 | — | 1.11 → 1.60 GB | 132 → 128 / 20 → 20 |
| textshelf | ASGI executor vs daphne, 3 sessions | 180 | 71,669 | 0 | SIGTERM ×1, 0.19 s | 176.1 → 158.7 MB | 96 → 92 / 17 → 16 |
| textshelf | WSGI, pool 8, no abandoners, 3 sessions | 60 | 24,588 | 0 | — | 497.3 → 195.2 MB (a startup transient) | 83 → 83 / 13 → 13 |
Every row: descriptors and threads flat, the server's free-slot count back at its starting 1,015 at every sample (every slot returned), zero 5xx, and 800–1,850 abandoners per run (half FIN, half RST) absorbed. The color-separation gigabytes are the application's image renditions, as in the 0.16.0 record; textshelf's executor RSS fell over the run, the sawtooth the record describes. No server defect.
Two rows failed first, and neither was the server. Recorded because each cost an hour and the next pass should not pay it again:
- transcripts, 2,798 "failed" stream reads — every read of the typst
PDF. Its normalised digest no longer matched the 2026-09-01 capture; a
fresh gunicorn from the same checkout produced the same digest as
m0serve, four fetches out of four, so the capture was stale: the soak's
own traffic had changed the database it renders from (
db/transcript.dbmodified that evening). Re-captured from gunicorn, re-run: the row above. The rule this adds to the driver's page: re-baseline before every pass, not only after a normalisation change — and before blaming the server, fetch the route from the reference server and compare. - textshelf, 3 of 3 logins answered 200 in both modes. The runs had
been pointed at the database the checkout's
.envs/.local/.djangonames — the development database — where no soak account exists; daphne answered the same 200. The soak's database is the scratchtextshelf_soak(the 0.16.0 record's setup), and the rows above are against it. A soak account created in the development database while diagnosing this was removed the same minute.
One observation, not investigated. textshelf's churn drain took 0.19 s with two stream clients and an abandoner in flight, where the 0.16.0 record measured 5.0 s ("held streams never end, the drain waits its budget by design"). What changed between is D9 (2026-09-02): the drain now runs ordinary loop passes, so a stream whose client has gone is closed rather than waited for. Consistent, and not a defect either way; a pass that wants the claim can open a stream, hold it, and time the drain.
What this pass did not do, unchanged from the record above:
--threads (none of the four dependency trees builds on free-threaded
CPython), NiceGUI (phase 0 only, no manifest), the proxy and worker shapes.
What would make this worth repeating
The shapes these four still lack: a WebSocket application (NiceGUI passed
phase 0 — its socket.io transport works in a real browser through the
executor — and has no manifest yet), a real proxy in front
(X-Forwarded-Proto → SECURE_PROXY_SSL_HEADER, which textshelf on Fly
will supply), a background worker, and free-threaded --threads. And a
week of real traffic, which is the plan.
The previous record. Run 2026-08-26, against 0.11.0 (the PyPI-shaped wheel,
dist/wheels/m0serve-0.11.0-py3-none-macosx_13_0_arm64.whl), macOS 26 on an
M4, CPython 3.13.6 — plus one row on free-threaded 3.14.7t. The plan this
replaces is in the history of this file.
Every application this server had been tested against was written to test it.
apps/ holds bare WSGI and ASGI apps that pin spec clauses, a Django demo
built around the realtime feature, and a FastHTML row — all small, all ours,
none carrying a dependency tree somebody else chose. That is the right shape
for a smoke suite and the wrong shape for the question "would this serve my
application?"
The gap was not hypothetical. The one time the wheel had met a real Django
project it produced the --app-dir shadowing defect
(Known issues). This pass produced four more,
three of which no in-repo app could have shown, and every one of them is now
fixed with a test that fails without the fix.
The three applications
Local Django projects, chosen because they stress different parts of the server rather than because they are convenient. Served from clean clones against scratch databases; their own settings, unmodified.
| app | shape | Django | what it stressed |
|---|---|---|---|
transcripts |
plain WSGI, src/ layout, allauth, 137 modules |
5.2 | the baseline — and the --app-dir case, since its code is under src/ |
color-separation |
image/halftone processing, numpy/scipy/Pillow, FileResponse |
6.0 | CPU-bound views, uploads, downloads |
textshelf |
production-shaped: 4 SSE endpoints, 3 pubsub modules, djstripe, WhiteNoise, Docker/Fly | 6.1 | the hard case — an app that already built what this server claims to remove infrastructure for |
What was found
Five defects. Four are fixed in this repo with a guard; one was already known and stays open on purpose.
| # | defect | how it presented | triage |
|---|---|---|---|
| 1 | Every Set-Cookie lost expires and SameSite |
Django's session and CSRF cookies reached the browser as sessionid=…; Max-Age=…; Path=/ where every other server sends expires=…; HttpOnly; Max-Age=…; Path=/; SameSite=Lax. Sessions still worked, so nothing failed loudly — but a persistent cookie became a session cookie for any client that prefers expires, and CSRF cookies shipped without their SameSite defence. On all three apps, on every response. |
fixed — ResponseCookieJar.raw, + test_response_cookies.mojo, + smoke-django asserts the attributes on the wire |
| 2 | Uploads between ~1.5 MB and --max-body were refused 400 |
color-separation's image upload (7.1 MB) failed with a truncated connection under --max-body 64m; on a bare echo app the threshold was between 1.5 MB and 2 MB whatever the flag said, including the default. The receive buffer had its own 2 MB ceiling that --max-body never raised, and it answers 400 Bad Request rather than the 413 the body cap sends — so the error named the wrong thing. |
fixed — ServerConfig.recv_buffer_limit(), + a test_config.mojo case, + smoke-serve now asserts a 3 MB body at the default cap and 7 MB at --max-body 8m |
| 3 | Concurrent ASGI streams truncated each other | textshelf serves its static files through WhiteNoise, so every page load is several FileResponses. Twelve concurrent fetches of one 232 KB file left bodies short — a clean 200 with fewer bytes — and enough of them wedged the executor entirely: the server stopped answering and SIGTERM needed 12+ s and then SIGKILL. Cause: the credit window is per stream (64 KB) while the chunk channel is shared and finite, so N streams over-commit it and send_stream_chunk dropped the datagram it could not place. |
fixed — a global in-flight budget in the shim (_ASGI_TOTAL_WINDOW), an owed-ack retry on the loop side, and a full channel that waits detached rather than dropping; + smoke-asgi runs 32 concurrent FileResponse-shaped streams and checks every byte |
| 4 | SIGTERM never returned when a handler thread was inside a response that never ends |
textshelf's SSE endpoints served under WSGI are buffered, so the generator never returns and its pool thread never comes back. stop_and_join waited for it forever: the drain finished, the process did not exit, and docker stop would end in SIGKILL after its grace. |
fixed — ThreadSet.join_within and a 5 s join budget matching the drain's, after which the process leaves and says what it left behind; + a smoke-blocking-threads phase with two never-returning views |
| 5 | --app-dir is appended to sys.path, not prepended |
Confirmed again at the time: a probe app reported --app-dir at position 5, after site-packages. It did not bite any of the three (no top-level module name collides with an installed package in any of them), which is why it was a latent hazard rather than a visible failure. |
fixed in 0.12.0 — prepend_to_path puts --app-dir at sys.path[0] the way gunicorn, uvicorn and runserver do, and smoke-serve's shadowing case (a project module named like an installed package) pins it; struck through in Known issues |
Findings 1, 3 and 4 could not have come from apps/: they need a real cookie
policy, a real static-file middleware, and a real never-ending response
respectively. Finding 2 needed a file somebody wanted to upload.
Phase 0 — will it even load?
m0serve --doctor against each entry point. All six passed ("ok": true,
exit 0): transcript_manager.wsgi, halftone_studio.wsgi,
halftone_studio.asgi, config.wsgi, config.asgi, and
transcript_manager.wsgi again under --threads 4 on 3.14.7t, which reported
free_threaded_build: true and mode: threads. Protocol detection was right
every time; the WSGI apps got the zero-config pool of 8, the ASGI apps got the
executor.
transcripts keeps its code in src/, so it is the shape the --app-dir
defect targets. It loaded from both --app-dir src and --app-dir . (it is
also installed as an editable package), and neither path was shadowed.
Phase 1 — parity against runserver
Same routes under m0serve and manage.py runserver, raw HTTP/1.1, no
redirect following, diffed on status, headers and body after normalising what
is per-process by design (Date, CSRF token, session id). Home, admin index,
admin login, allauth login, a static asset, a 404, a 500, and a full login
round trip on each app.
After the cookie fix, every remaining difference is one of three, and all three are the server being itself:
connection: keep-alive— m0serve sends it explicitly;runserverdoes not.x-thread: N— which handler-pool thread served the request. Documented, and absent under--blocking-threads 0.- Django's debug pages embed the request's own
host:port, which differs because the two servers are on different ports.
Bodies were byte-identical on every route on all three apps, admin included.
The login round trip — GET the form, POST credentials, GET the
destination with the session — produced identical headers at all three
steps on color-separation and textshelf. Before the fix the same
comparison reported 15–24 differences per app, every one of them a cookie.
Two app-level failures appeared identically under both servers and are the
applications', not ours: transcripts answers 403 on /accounts/login/
(its dev auto-login middleware logs you in, and allauth's login view forbids
an authenticated user), and textshelf answers 500 on /accounts/signup/
(no SocialApp row in a scratch database).
Phase 2 — the feature matrix
| property | result |
|---|---|
static via WhiteNoise (textshelf) |
works; found defect 3 under concurrency on ASGI. After the fix, 200 fetches of a 232 KB file at concurrency 16 take 0.35 s against uvicorn's 0.29 s on the same app |
static via --static PREFIX=DIR |
works (smoke-serve covers it; not re-exercised here) |
| upload > 4 MB | found defect 2. After the fix: a 7.1 MB multipart upload under --max-body 64m reaches Django, is validated, stored, and processed. Over the cap the answer is a legible 413 Payload Too Large before the body is read |
StreamingHttpResponse under WSGI |
buffered, as documented — and for textshelf's never-ending SSE generators that means the response never completes and the thread is held. Legible? No. The client sees an open connection that yields nothing; nothing is logged. This is the documented limit meeting an app that does not know it, and it produced defect 4. Since 2026-08-27 it streams: a pool thread produces it through the loop's chunk channel, chunk by chunk (smoke-wsgi-stream); the thread returns when the client leaves, and a generator asleep between events is the bounded straggler at shutdown rather than a hang |
StreamingHttpResponse under ASGI |
streams for real, chunk-framed |
FileResponse / download (color-separation) |
byte-identical zip to runserver, correct Content-Type, Content-Length and Content-Disposition |
| long-running view | color-separation runs its whole separation pipeline synchronously inside the request (ImmediateBackend); served correctly in every mode |
| view that calls out over HTTP | not reached by these apps' fixtures; the documented _scproxy hazard is unchanged and pinned by smoke-wsgi |
| management commands | check, makemigrations, migrate, collectstatic, createsuperuser all ran against the same settings; that is how the scratch databases were built |
Phase 3 — the topology matrix
Each app, each mode: a route sweep, a 200-request burst at concurrency 16, and 16 concurrent logins (a session write per request).
Every mode served every route correctly on all three apps, with zero 5xx
and zero dropped connections in the bursts — --workers 1, --workers 4,
--blocking-threads 4, --workers 4 --blocking-threads 4, and --threads 4
on free-threaded 3.14.7t (transcripts, which is the app whose dependency
tree is thread-clean).
The 16-concurrent-login column found nothing about the server and one thing
about the fixture: textshelf answers some of them 500 with
sqlite3.OperationalError: database is locked. That is the scratch SQLite —
the project runs PostgreSQL — and it reproduces at the same rate under
gunicorn --workers 4 and under runserver, so it is attributed there
rather than here. transcripts answers all 16 with the same app-level 403
as in Phase 1. textshelf also rate-limits its own auth endpoints (429),
which is the application working as designed.
Phase 4 — the realtime retrofit (textshelf)
The claim is that a plain sync view can hold an SSE stream with two response
headers and no added infrastructure. textshelf/notifications/ was the
honest test: an async notification_stream view, an async_subscribe
generator over PostgreSQL LISTEN/NOTIFY, a polling fallback for SQLite, a
sync copy of both, and a hard dependency on running under Daphne.
The conversion is 20 lines of view:
@login_required
@require_http_methods(["GET"])
def notification_stream(request):
response = HttpResponse(f"event: connected\ndata: {head}\n\n",
content_type="text/event-stream")
response["M0-Hold"] = "stream"
response["M0-Channel"] = NotificationPubSub.get_user_channel(request.user.id)
return response
plus one m0pub.publish beside the existing pg_notify in the publish path,
deferred to commit the same way.
Measured result: +52 / −293 across two files — a net 241 lines removed,
because holding the connection in the server strands all four subscription
implementations (sync, async, and a polling fallback for each: 236 of
pubsub.py's 336 lines), and with them the psycopg import that existed
only for LISTEN/NOTIFY.
It works: four EventSource clients held across two prefork workers, one
POST to an ordinary synchronous Django view, and all four receive the same
numbered event. No Daphne, no asyncio, no LISTEN/NOTIFY, and the database
is SQLite — where the code being replaced had no delivery mechanism at all,
only a 5-second poll.
The degradation is better than what it replaces, which was the surprise.
Under gunicorn the converted view answers a short buffered
text/event-stream that the browser's EventSource reconnects on — the GRIP
property. The original async view under gunicorn hangs: 12 s with no bytes,
and the worker never serves again. So this is not a change that trades
portability for the feature.
The honest caveats: the app loses its SQLite polling fallback (nothing
delivers under gunicorn now, where before it polled — at the cost above), and
M0-Channel is one channel per connection, which suits a user-scoped
notification stream and would not suit an endpoint that multiplexes several.
Verdict: a diff a maintainer would accept. The claim survives contact.
Phase 5 — soak
6,000 keep-alive requests per app over a mixed route set (home, admin, login, static, 404, an authenticated page), sampling RSS, open descriptors and threads.
| app | RSS at 1k → 6k | fds | db fds | threads | errors |
|---|---|---|---|---|---|
transcripts |
103.6 → 105.1 MB | 34 → 34 | 0 | 13 | 0 |
color-separation |
99.6 → 101.6 MB | 71 → 71 | 0 | 13 | 0 |
textshelf |
261.4 → 267.9 MB | 52 → 52 | 8 | 13 | 0 |
Descriptors and threads are flat — nothing accumulates per request, and
Django's CONN_MAX_AGE = 10 holds its connections steady rather than growing
them. RSS rises 1.5–6.5 MB over the 5,000 requests after warm-up and then
stops, which is Python's allocator reaching steady state rather than a leak;
smoke-django's 10k-request guard still measures 0 KB against the bare app.
Throughput was 363–819 rps single-worker on a laptop, which is not a
benchmark and is not offered as one.
What this changes about the guards
Every fix landed with something that fails without it, and each was checked by sabotage rather than assumed:
test_response_cookies.mojo— 4 of 6 cases fail whenadd_rawis reverted to parse-and-reserialise.test_config.mojo::test_recv_buffer_limit_covers_headers_plus_body— fails whenrecv_buffer_limit()returns the bare field.smoke-django— now reads the session cookie off the wire and requiresexpires,HttpOnly,Max-AgeandSameSite=Lax, because curl's cookie jar stores name and value only and could never have seen this.smoke-serve— a 3 MB body at the default cap and 7 MB at--max-body 8mmust be200; 5 MB and 9 MB must be413. Both sizes straddle the old ceiling.smoke-asgi— 32 concurrent 4 KB-piece streams, every body byte-exact. This one paid for itself on its first CI run, and is why the fix looks the way it does. It passed on macOS and failed on Linux: two streams short, because the budget had been sized against a 256 KB socket buffer and Linux clampsSO_RCVBUFtonet.core.rmem_maxand charges each datagram's wholeskbagainst it. Raising the number would have made this platform pass and left the next to find out, so the number stopped being load-bearing instead — a full channel now waits (detached, so the loop can drain it) rather than dropping. Verified by removing the budget rather than trusting it: at_ASGI_TOTAL_WINDOW = 64 MB, effectively no budget at all, 64 concurrent streams still deliver every byte and log no drops.smoke-blocking-threads— two views that never return,SIGTERM, and the process must exit inside 20 s naming what it abandoned.
Revisited after stage 1 (2026-08-26)
--realtime composing with --blocking-threads changed the answer this
record originally implied, so the question was re-measured rather than
re-reasoned. Same laptop, same scratch SQLite, five events 0.3 s apart,
arrival time of each event recorded by a client that reads incrementally —
curl -w %{time_starttransfer} will not do, because several of these
servers send an empty first body chunk that satisfies it.
| server | a sync generator | an async generator |
|---|---|---|
runserver |
0.31, 0.61, 0.92, 1.23, 1.53 — streams | all at 1.51 — buffered |
| daphne (their production) | all at 1.53 | 0.30, 0.61, 0.91, 1.21, 1.51 |
| uvicorn | all at 1.53 | 0.30, 0.61, 0.91, 1.21, 1.51 |
| m0serve, ASGI executor | all at 1.53 | 0.31, 0.60, 0.91, 1.21, 1.51 |
m0serve, --realtime --blocking-threads |
all at 1.53 | all at 1.51 |
Two things fall out, and the first is about their application rather than
any server. textshelf's AI streaming endpoints do not stream, on their
own production server. ai/views.py:summarize_submission is a sync view
returning create_sse_response(...) over
services.summarize_document_stream, whose annotation is
Generator[str, None, None] — a sync generator, which Django's ASGI
handler must consume before it can serve it (it says so in a warning). Every
ASGI server buffers it identically; the token-by-token delivery the endpoint
is written for arrives in one lump at the end. The rows above are a
same-shaped instrument rather than their endpoint (which needs an API key),
but the shape is what decides it, and the shape is theirs.
The second: m0serve's executor matches uvicorn and daphne to the millisecond in both rows. Whatever streams under them streams under it, and what buffers buffers everywhere.
That changes the recommendation this record's Phase 4 pointed at. Before stage 1, holds cost the pool, so the hybrid mount was the only shape with both isolation and working streams. Now:
| shape (4 workers) | pub/sub streams | AI endpoints | fast path, 8 slow views | app changes |
|---|---|---|---|---|
--realtime --blocking-threads 4 |
held by the server: +2 MB per 200, no Python state, no database connection | buffered — exactly as they are today under daphne | 0.3 ms | convert 4 pub/sub endpoints (the notifications one was −241 lines) |
--mount /=wsgi --mount /rt=asgi, pool 4 |
executor: one Postgres connection each in production | buffered — as today | 0.7 ms | none but a URL prefix |
| daphne, as deployed | as today | buffered | 4.9 ms | none |
The AI endpoints cost nothing to move, because they are already
buffered wherever they run. So the choice was between the cheapest way to
hold a stream and no application changes at all — and what forced it to be
a choice was that --realtime and --mount could not be combined
(measured the day this was written: exit 1, refused before the bind).
That is no longer true, and this pass is why. The ROADMAP entry it
produced was implemented the same day: --realtime now composes with
--blocking-threads, with --mount, and with WebSocket holds under both
(ROADMAP, Hold on a pool thread, stages 1–3). So the row this table calls
"convert 4 pub/sub endpoints" no longer costs the pool or the ASGI mount —
an application can hold its pub/sub streams, run its generator streams on
an executor mount, and keep pool isolation for its slow views, in one
process. What is left for textshelf is application work: converting the
four endpoints, and making ai/streaming.py's generator async so it
streams at all.
Re-soak — 2026-08-31, against 0.16.0, textshelf only
Five minor versions landed in five days after the record above — --mount,
per-mount lanes, the batched pump, ExecutorPort, the WebSocket credit gate,
the close linger — all on the request path, none of it seen by a real
application. textshelf was re-run first because it is the app with four SSE
endpoints, so it stresses the seam that churned most.
It found one defect, and the defect was on the ASGI path the WSGI record
above could never have reached. Clean clone, scratch SQLite,
config.settings.local, the 0.16.0 wheel, CPython 3.13.6, macOS 26 on an M4.
| phase | result |
|---|---|
| 0, load | --doctor ok: true on config.wsgi and config.asgi; protocol detection right, WSGI got the pool of 8, ASGI the executor |
| 1, parity | 10 routes. Two apparent differences against runserver both resolved in m0serve's favour by measuring gunicorn: static-file headers (runserver's StaticFilesHandler bypasses the middleware stack; gunicorn's headers are byte-identical to m0serve's) and the debug page's env table (wsgiref copies os.environ into the environ, gunicorn and m0serve do not). Against gunicorn the only m0serve-only header is x-thread, and every residual environ key is a legitimate per-server one — wsgi.multithread: True correctly reporting the pool |
| 3, topology | --workers 1, --workers 4, --blocking-threads 4, --workers 4 --blocking-threads 4, --blocking-threads 0, and ASGI. Every route correct, 192/192 burst at concurrency 16, 16/16 concurrent logins, zero 5xx. --threads not run: psycopg-binary ships no free-threaded wheel, so the 3.14t venv cannot be built |
| 5, soak | 6,000 keep-alive requests over a mixed route set |
| RSS 1k → 6k | fds | db fds | threads | errors | |
|---|---|---|---|---|---|
| WSGI | 167.2 → 171.5 MB | 69 → 69 | 8 | 13 | 0 |
| ASGI | 177.5 → 216.0 MB | 58–110 | 3–56 | 7 | 9 truncated |
The WSGI row matches the 0.11.0 shape: +4.3 MB over 5,000 requests, flattening
to +0.4 MB across the last 2,000, with descriptors, database descriptors and
threads all flat and the thread count identical at 13. 540 rps, inside the
record's 363–819 range. The ASGI descriptor and RSS churn is asgiref's
thread pool opening a connection per thread — application behaviour, not a
leak.
The defect: the keep-alive cap destroyed the response it fired on
Nine of the 6,000 ASGI requests came back truncated, all of them on the same
124 KB WhiteNoise file, at intervals of exactly 700 requests — every hundredth
time that route was hit. max_keepalive_requests was 100 (the default
then; 1000 since notes/pool-tail.md, which found the
cap itself to be the fast route's p99).
Isolated on one connection, and the two faces are the same bug:
| request 100 of a keep-alive connection | before | after |
|---|---|---|
streamed body (124 KB FileResponse) |
200, Content-Length: 124926, 0 bytes |
complete |
| WebSocket upgrade | 101, no frame ever |
101 and a live echo |
| ordinary 45-byte response | Connection: close + complete body |
unchanged |
| same file under WSGI | Connection: close + complete body |
unchanged |
_finish_response clears should_close for a stream and for a 101, because
each owns its connection until it ends. The cap check below those branches was
guarded by not should_close — precisely the state they had just established
— so the two shapes that had opted out were the two it caught, and
_after_send closed the slot as soon as the head drained, before any body
frame arrived over the chunk channel. Both faces are silent: correct status
line, nothing in the server log.
Gated by poe smoke-keepalive-cap (SPEC A3), whose third phase asserts the
cap still fires for an ordinary response — without it the gate would pass on
a build whose cap never fires. Four sabotages: reverting the fix, each guard
alone, and raising the cap, each caught by the phase that should catch it.
The m0serve X.Y.Z marker in this file's opening line is the ONE the
milestone gate reads, and it deliberately stays at the version the full
three-application pass was run against. A second marker anywhere in the page
would become a fallback for _real_app_version and silently defeat the
sabotage that proves the gate can miss an unreadable record — which is why
this heading says "against 0.16.0" rather than naming the binary.
This pass is not the 1.0 soak. transcripts and color-separation have
not been run against 0.16.0, so the record above still stands at 0.11.0 for
them and the milestone stays stale until they are.
What would make this worth repeating
It already was: four defects in one pass, three of them invisible to any
application written to test this server. The next pass should use applications
with shapes these three lack — a background worker, an app behind a proxy with
SECURE_PROXY_SSL_HEADER, an upload-heavy API, and something that is not
Django.
The application layer
Last run 2026-10-09, against m0-http 1.14.0 — unotes at 45a1cdd, built by the m0 0.11.0 wheel cut from the release branch at 57e9150, ahead of its upload, macOS 27 on an M4.
The application is unotes, a
reader over a private notes corpus, written outside this repository on the
documented path: m0 new from m0 0.1.0 as published, Views and
Fragment[Htmx], a login on m0_http.login, the corpus read from SQLite
through m0_sqlite, deployed as an image to Fly.io, and taken through
four upgrades (0.2.0 to 0.5.0) with m0 doctor. Its SOAK_LOG.md is
the running record. It was written to exercise the layer, by this
repository's owner: it is the layer's first application outside apps/,
not an independent adopter, and what it found is rows here already —
Query (N36), push=True (N37), HEAD answered by GET (N38), SQLite opened
at run time (O17, O18, N39, N40), the scaffold's login guidance, and the
login module and auth template (N43–N45).
The synthetic soak. scripts/soak.py against a local bin/server on
the real corpus, tools/soak_manifest.json its manifest, driven by
bench/soak/2026-10-09/layer_unotes.sh. The
reference is the same binary's answer to each route alone; every response
under load is compared with it, status, headers and a digest of the body,
the scan figure and the CSRF token normalised. Six keep-alive bursts over
eleven routes, nine of them behind the session, four sessions signed in,
two clients that abandon a response mid-body (FIN and RST alternating,
paced at 50 ms) and at once reuse the slot, and a SIGTERM and restart
every 40 s with everything in flight.
| row | seconds | verified byte for byte | failures | restarts | worst drain | RSS | fds, threads |
|---|---|---|---|---|---|---|---|
| two workers, forked | 180 | 1,762,770 | 0 | 4, each exit 0 | 4 ms | 17,264 → 17,328 kB | 55 → 54, 2 → 2 |
| two loops on threads | 90 | 881,953 | 0 | 2, each exit 0 | 44 ms | 23,008 → 23,104 kB | 37 → 37, 7 → 7 |
6,211 and 3,065 abandonments in the two rows. A request that met a restart
and was refused or cut is counted apart and not as a failure (12,862 and
6,855), within a window of at most 524 ms a restart. The login population
stays off (--bulk 0): the manifest sets no interval for it, and on
2026-10-02 it signed in about 170 times a second until the client ran out
of local ports, every failure OSError 49 on a connect and no response
wrong (unotes' SOAK_LOG, finding 15).
The application's source is its upgrade to m0 0.7.0; only the pin
moved, in a scratch clone, to a wheel built from this release's tree.
Nothing in it names a package export this release removes or a name it
renames, so it built unchanged, in 15 s with no warning. m0 doctor
read framework 1.14.0 and passed its six checks, m0 test passed 3 of 3
files (37 tests), and smoke.sh passed. The 2026-10-06 run, on 1.11.0,
verified 1,409,040 and 814,210 in the same times, with the same zero; the
rows assert bytes, not rate.
The deployed instance, unotes.fly.dev on a shared-cpu-1x, on
m0 0.4.0 since 2026-09-30. Signed in on 2026-10-02: the list, a note and
the themes page each answer 200, and the list signed out is a 303 to the
login. The corpus scan on that CPU, x-scan-us, six requests of each:
| query | µs |
|---|---|
| no filter | 6–9, and 60 on the first request |
q=the |
164–189, and 573 on the first |
q=football |
1,227–1,517 |
q=zzzzqqqq (no match, the whole corpus read) |
1,256–1,595 |
The same figures as on 0.1.0. A deploy with a reader mid-session, and a phone that sleeps and reconnects, were taken on the deployed instance on 2026-09-24 with the phone emulated (the application's log, "the real use the log owed").
What this does not show. The framework as published: this run's is
the release branch's, in a wheel not yet on the index. Traffic from anyone but its owner, an
application with writes (unotes reads), a stream, or a second author's
habits. The layer's write path is gated by apps/fragment_notes,
apps/datastar_todo and apps/table_notes inside the tree and has no
soak outside it.
textshelf, first named here, is not the soak: its streams moved onto the built-in hold mount in production on 2026-09-12 (above), and it writes no Mojo.
A record here opens with ONE line that poe milestones reads, the way the
server's does:
**Last run DATE**, against m0-http X.Y.Z — on a single line; the version is the framework the application was built from, versions.framework in m0 doctor --json, not the m0 wheel's own number, which the reader does not accept.
It then names the application and the path it moved onto the layer, and carries the server soak's table: requests verified byte for byte against a reference, failures, churn, RSS. The milestone applies the server soak's staleness rule to that version.