The Mojo host — built 2026-09-16
A design note from the engineering record. What a Mojo application's
mainused to be, what the host (lightbug_http.hostas written,m0_hostsince 2026-09-18) took out of it, the choices it makes for every application, and what it does not do yet.
The problem. m0serve is a host: it owns the listener, the workers, the
bus, accept sharing, signals and the drain, and a Python application
brings only itself. A Mojo application had no host. Each one wrote its
own main from the comments in CLAUDE.md's runtime constraints, and the
reference applications got those rules wrong. apps/datastar_counter
joined the bus only above one worker. apps/sim_loop published to worker
0's channel alone under a comment saying otherwise. No application shared
its accepts, so under M0_WORKERS=2 one worker took nearly every
connection. Those rules are the repository's hardest-won, and they were
being re-derived by hand in every main: 105 lines in sim_loop, 77 in
apps/blobs.
The host. serve[H, P](config) is everything in that main that is
not the application:
def main() raises:
var cadence = Cadence() # the app's own configuration check
if not cadence.valid():
...
serve[BlobsHandler, BlobsProducer](AppConfig())
H conforms to AppHandler: an HTTPService plus a static make(ctx),
and optionally page_slots(workers) for a pre-fork page of the
application's own. P conforms to Producer: make(ctx) and
step(mut self, mut out: Publisher) -> Int. NoProducer is the default.
Construction is a type parameter with a static make, not a function
value, for the reason PoolHandler gives: Mojo 1.0 cannot turn a
function-parameterised def into the address a pthread needs.
serve does these steps in this order, and the order is the point:
- It refuses
M0_THREADS,M0_BLOCKING_THREADS,M0_SPAWN_WORKERSandM0_WORKERS=0with exit 78, before anything is bound. - It listens once, in the process that will fork.
- It creates the host's shared page (m0serve's layout: slot 0 the event id, slot 2 the magic word, then each worker's accept-share line) and, if the application asked for one, a separate application page.
- It creates the bus unconditionally, at one worker too.
- It creates the accept-share channels above one worker, unless
M0_ACCEPT_SHARE=0asks for the bare race. - It forks when
M0_WORKERS > 1. - It binds this worker's accept share.
- It arms the signals, after the fork.
- It builds this worker's handler with
H.make. - On worker 0 alone, it starts the producer.
- It serves, with this worker's bus channel drained.
- It stops the producer and joins it within 5 s. A producer still
inside a step at that point is left behind by name, and the process
leaves with
_exit. - A forked worker ends with
exit_worker().
Two things make the wrong wiring impossible to write rather than merely
documented. The application never sees a bus descriptor: Publisher
holds all of them and sends to all of them with skip_worker = -1, so a
producer cannot publish to a subset. And nothing in the application
decides whether it has forked.
The three choices a helper would have had to make
DECISIONS D26 declined a worker-thread helper because it "would have to choose the cadence policy, the shutdown bound and the publish shape on the application's behalf, and one application is not evidence of which choices are right". By 2026-09-16 there were two applications, and they disagreed on cadence, so the choices are made here once and recorded as D27:
- Cadence.
stepreturns the time from this step's scheduled start to the next one. The schedule is fixed-rate and never catches up: after a step overruns, the next one is scheduled from now, rather than the missed steps running back to back.sim_loop's fixed 4 Hz isreturn period.blobs' pause with nobody watching isreturn PAUSE_POLL_NSwithout publishing, and its 2 Hz idle rate is a different return value. The sleep between steps is sliced at 50 ms, so a producer with a 60 s period stops at once. Returning the period fromstepbeat a separateperiod_ns()method: blobs decides its next period in the middle of its step, from the same clock read. - Shutdown bound. The drain's own 5 s,
JOIN_TIMEOUT_NSfrommojo_pool.mojo. After that the host printshost: abandoned a producer step still running 5 s after the drain; exiting without itand calls_exit(0).pthread_joinhas no timeout, so waiting longer only turns SIGTERM into a no-op untildocker stopsends SIGKILL.ThreadSet.join_withinwaits on the body's status slot, which the host's producer body writes as its last act. - Publish shape. Every worker's channel,
skip_worker = -1, with each refusal counted on the publisher and reported to the step asFalse. Blobs counts refusals in/statsas before. A step that raises ends the producer and is named in the log, while the server keeps serving what it has. A producer that restarted itself after a raise would repeat the failure at its cadence.
The producer is built on its own thread, from a copy of the context in
memory the host never frees, because an abandoned producer outlives
main's locals. ProducerThread is a struct separate from serve, so
test_host.mojo can drive it with a real bus and no server.
Where it lives: the fork (D28)
Retired 2026-09-18. The host is now the package
m0_host, and the reason it is not insidem0_httpis a different one: the-host-leaves-the-fork.
The owner decided this before building, on the handoff's recommendation.
On the pinned toolchain, an application's conformance to a trait in a
precompiled package gets no witness table. The cause is that every
package here builds a directory named src into <name>.mojoc, so
traits are recorded under the directory's name and looked up under the
package's (poe check-mojoc-trait; fixed on the nightly). A
source-resolved module has no such mismatch, and mojo_pool.mojo is the
precedent. The cost is six more fork-to-framework imports (config,
multiworker, signal, threads, views since round 2 and prefork
since round 3), all inside packages/m0-http/.
CLAUDE.md's cycle paragraph lists them, and poe check-fork-package
still compiles the fork whole. The second decision, the toolchain pin,
stays at 1.0.0: no stable release carries the fix, and pinning a product
to a nightly is a separate decision.
What v1 does not do (D29)
The host has no pool lanes, no loops on threads, no exec'd workers, no CLI
flags and no --doctor. Its whole configuration is AppConfig's
environment. The three variables that name m0serve's other modes are
refused rather than ignored. An ignored variable is a configuration that
reads as applied, which is the class of defect this repository keeps
finding when it gates an ungated row. A host that offers pool lanes and a
bus will have to call run_event_loop directly for peer_bus_fd, as
m0serve does. No application under apps/ needs that yet.
(Later: pool lanes arrived with D31, loops on threads with D35, and flags
and --doctor on 2026-09-19 —
flags-and-a-doctor-for-the-host.
Exec'd workers are still refused.)
The handler is built after the fork and the pages before it. Neither
order is a one-line edit in serve, so neither is sabotaged on its own.
The two-worker phases are what would fail: one handler cannot hold
streams in two processes, and a bus created after the fork reaches no
sibling.
Blobs on two workers
apps/blobs served from one process until the host existed, with
M0_WORKERS above 1 refused. Its board was already laid out per worker,
so the port was a sizing change (page_slots(workers) is
board_slots(workers)) plus a producer that sums every worker's viewer
word. The board lost its three configuration words: the producer now
reads its cadence from the environment itself. main went from 77 lines
to 15, and the file from 480 to 428.
smoke-blobs' refusal phase became a two-worker phase
(blobs_probe.py two):
- Spread. Streams are opened until they sit on both workers, which
each names in
x-worker. A run that cannot spread them fails as vacuous. - Every step. Every stream carries every step, contiguously, at the same step as its siblings.
- A crossing click. A keep-alive connection that
/statssays is held by worker 1 posts a drop, and both workers' streams must draw it. This is the first test of the drop box's multi-writer design across a process boundary; it had been tested by construction only. - The viewer sum. With worker 0's streams closed and worker 1's held,
/statsmust count worker 1's viewers and the producer must keep stepping. Then it must pause once they close.
Two of sabotage-blobs' rules are new. One counts worker 0's viewers
alone. The other gives worker 1 a board of its own, so its click never
crosses. The rules that moved into the host left the blobs list: the
skipped channel, the stop that is never sent, and the refusal itself.
The gates
-
poe smoke-host(every PR, SPEC E21–E23) runs onapps/host_check, a handler and a producer and nothing else. Each server gets its own port. It checks:- a stream at one worker beats, so the bus is drained there;
- four streams spanning two workers each carry every beat, with no id repeated (one producer) or skipped (every channel);
- a SIGTERM to the supervisor alone ends four held streams, both workers report a clean exit, and none outlives the supervisor;
- accept sharing, through
accept_spread.py --app-bin, lands bursts within 2:1 with hand-offs; on macOS the knob-off arm must skew; - a 60 s step leaves 5 s after SIGTERM with the producer named, in one process and in a forked worker;
- a 60 s period leaves at once;
- each refused variable exits 78, names itself and never prints the listening banner.
-
test_host.mojo(every PR) holds the thread rules without a server: every channel in order, no catch-up, a prompt stop, the bounded join, a raising step's status, the refusals, and a counted refusal on the publisher. -
poe sabotage-host(pre-release) breaks nineteen rules (sixteen in round 1). A sabotage that does not compile is reported as BROKEN and counted as a miss. Its first run missed two rules, and both misses taught something:- A producer in every worker was invisible. Each stream still saw every beat id once, because the loop's redelivery filter keeps the newer of two racing ids: two producers in lockstep look like one. Each beat now carries its producer's pid, and one run must see exactly one.
- Catching up after an overrun was indistinguishable from not. When every step costs more than its period, both schedules run the steps back to back. The test now overruns once and then steps freely, and catching up doubles the steps in the next window.
A seventeenth rule was dropped rather than guarded: removing the
_exitafter an abandoned producer. A forked worker leaves throughexit_workeranyway, and a single process returning frommainwith the producer asleep in its step exited 0 in the same time. The_exitstays, so teardown never runs under a thread that is mid-step, but nothing on the wire can tell, so it is not claimed.Two harness bugs turned up as well. The first draft moved the signal install by adding a pre-fork one, which the post-fork install then repaired; the duplicate declaration would also have been "caught" by the compiler. And
mojo runprintserror: execution exited with a non-zero resultfor a failing test, which a loose compile-error check read as a broken sabotage. A compiler diagnostic names a file, line and column; only that is counted as a compile error now.
What the smoke measured on an M4 running macOS 26 (one run each):
| arm | result |
|---|---|
| accept sharing | bursts of 32 split [16, 16] twice; 45 hand-offs |
M0_ACCEPT_SHARE=0 |
a burst of 32 split [27, 5] |
| overrun | exit 0, 5 s after SIGTERM, one worker and two |
| long period, drain | exit 0 within a second |
Found on the way: a supervisor that outlived its SIGTERM
One sabotage made a forked host worker return from main instead of
calling exit_worker(). The runtime's teardown is unusable after a fork, so
the worker crashed on its way out. The smoke caught it, and then left the
process behind: a supervisor still running eight minutes later, with two
fresh workers holding the port the next rule's server tried to bind. The
next rules were served by that stale, sabotaged binary, which is why the
first run's later results were re-run.
The cause is in WorkerSupervisor, not the host. A SIGTERM sent to the
supervisor alone is forwarded to each worker, and supervision then waits
for them. A worker that exits 0 is retired, and one killed by the forwarded
signal ends supervision. But one that crashes is respawned, exactly as a
crash in service would be. Nothing ever signals the replacement, so the
supervisor serves it until something sends SIGKILL. For docker stop that
is the whole grace period, then a kill. A worker whose drain fails in any
way (a teardown crash, a non-zero exit) hits this, under m0serve as much
as under the host.
The handler now records the stop, one word in the data segment beside the
child PIDs it already reads, before it forwards the signal. _try_respawn
refuses while the word is set, and the supervisor exits 1 once the rest are
gone. test_respawn.mojo pins it (SPEC D10) and fails with the guard
reverted. The smokes now reap whatever runs from their own temporary
directory on exit, so a supervisor that does outlive its signal cannot
serve the next run.
Round 2: the other four
The plan's port list was sim_loop, datastar_counter, datastar_todo
and fragment_notes, with each smoke kept green without touching its
assertions.
| app | main before |
after | what it gained |
|---|---|---|---|
sim_loop |
105 | 19 | nothing new; its four hand-kept rules became the host's |
datastar_counter |
56 | 7 | shared accepts; the bus joined at any worker count |
datastar_todo |
18 | 7 | two workers over one database (N17) |
fragment_notes |
15 | 10 | M0_WORKERS=2 refused instead of ignored (N18) |
sim_loop needed one thing from the host: a server config it could
adjust, because its on-loop arm sets app_tick_ms. serve takes one as an
optional second argument. The on-loop arm's Producer.wanted answers
False, so no thread starts. The docstring's four numbered rules are the
host's now, and the note says which of them the app once broke.
datastar_counter's page moved from three words on a hand-made page to two
words on its own page (page_slots); the event id is the host's. It joins
the bus unconditionally. The old guard (workers > 1) was right for that
app and wrong for any app with a producer, and a comment was all that
warned against copying it. At one worker the stream has no peer, and
publishing reaches nobody.
datastar_todo was the one that needed thought. Its sse_peer_frame
existed but never ran. Joining the bus made it live, and serving two
workers over one SQLite file turned out to be two lines — plus one race.
A mutation renders the whole list and then numbers the frame, and a tab
keeps the frame with the newest number. Consider two workers:
- Worker A renders the list.
- Worker B commits a change and renders a list that includes it.
- B numbers its frame 5.
- A numbers its frame 6.
Frame 6 lacks B's change, and every tab keeps frame 6 until the next
mutation. The fix is SQLite's write lock (BEGIN IMMEDIATE), held from
the change until the frame is published. The next writer waits on it, so
renders are serialized across processes in the same order as their ids.
The gate reads the broadcast log straight from the database file, and
under an add-only load every frame must hold every todo its predecessor
held.
The race is real and very short. Without the lock, fifty concurrent adds
passed that check 10 of 10 rounds. With a 5 ms pause between rendering and
numbering (M0_TODO_RENDER_PAUSE_MS, the app's gate knob, like
M0_SIM_ON_LOOP), the unlocked build failed 5 of 5 rounds and the locked
one passed 5 of 5. So the gate runs with the pause. A gate that could not
tell the two builds apart would be evidence of nothing.
fragment_notes keeps its notes in a struct, so a second worker would
serve a second, different list. It had silently ignored M0_WORKERS. The
host gained two small things for it:
AppHandler.max_workers(), where the app says it serves from one process and the host refuses a larger count with 78 before binding;ViewsApp[S], the host'sViewService, so aViewstable and its state (ViewState:make,urls) are served with no handler struct.
The table function was renamed note_urls so the state's static urls
does not shadow it. ViewsApp is also the piece Phase 3 needs: one views
module, served under a mount and under the host.
None of these needed an escape hatch in the host. The two additions are
declarations (max_workers) and a convenience (ViewsApp), and neither
lets an app reach around serve.
Round 3: one preparation for both hosts
The plan's last round moves m0serve's Python-free startup pieces down to
where both hosts call them. m0_http.prefork now holds:
prefork_page(workers, required): the shared page, file-backed where the host allows it, the magic word stored in slot 2,M0_SHARED_ID_FDandM0_SHARED_ID_ADDRexported;requiredis m0serve's--spawn-workers, where an anonymous page is no page at all;prefork_bus(channels)andprefork_accept_share(workers): created, kept across exec and exported, or adopted;bind_accept_share,shared_id_addr,spawned_worker_indexandint_list_env, moved fromm0serve.mojo.
The signatures narrowed on the way down. Each piece used to read
ServeOptions; what either host decides is a worker count and whether
the page must be file-backed, and that is all the functions take.
m0serve's _prepare_realtime is three calls over them, keeping only what
is its own: channels (workers or loop threads), required, and
M0_CORE_LIB for m0pub. Its flags and the M0_* names m0pub and an
exec'd worker read are unchanged, so the served contract is unchanged and
textshelf needs no follow-up.
Two things changed in behaviour, both on purpose:
- The Mojo host's page is file-backed and exported, as m0serve's has
been since #322. In round 1 it was an anonymous mapping, enough for
forked workers and nothing else. A child process a host application
starts can now number its frames from the page it inherited, the way
m0pubdoes. - A spawned worker with no page descriptor is refused. m0serve's old
path skipped the adoption silently when
M0_SHARED_ID_FDwas missing and went on to bind accept sharing to the address its parent exported, which in a fresh image is nothing.prefork_pageraises there, andhost_refusalrefuses an inheritedM0_WORKER_SPAWNEDoutright, since the Mojo host never sets it and would adopt from descriptors it was never handed.
Exec'd workers for the Mojo host are now a small step, adopting
M0_LISTEN_FD and calling enable_spawn, and are still refused (D29):
no application under apps/ needs one, and a mode nothing exercises is
a mode nothing gates.
test_prefork.mojo runs the adopt path in one process, which is enough:
a spawned worker is a fresh image that maps the same descriptor again,
and a second mapping in the same process is exactly that. SPEC E24 is
the row; the exec itself stays E15's. sabotage-host grew six rules
against that file, twenty-five in all.
Round 4: the host's contract
A review of the host end to end, after round 3, found two defects that all twenty-five sabotages had survived. Both are the host's own contract rather than an application's, and both were measured on 2026-09-17 before being fixed.
The host hid the bus descriptors but not the id space.
Publisher.publish took an id the application chose, and every producer
chose one from a counter of its own: host_check's Beat.n, blobs'
step_no, sim_loop's step_no in its producer arm, while its on-loop
arm numbered from the shared word, so one application disagreed with
itself. The handlers' publishes were already right: DatastarStream
takes the shared word under enable_bus. What it costs is the loop's
redelivery filter, which delivers a frame only if its id is above the
slot's last-seen id. When the supervisor respawns worker 0, a producer
numbering from its own counter restarts at 1, and every stream held on a
sibling is silent until the new counter passes the old one:
| observation | value |
|---|---|
| beats on worker 1's held stream before the kill | 41 over 4 s |
| silence on that stream after the respawn | 4.21 s, the pre-kill uptime |
| a stream opened fresh on worker 1 after the respawn | beats at once |
| the same held stream, fixed | a beat 12 ms after the kill |
For blobs at 10 Hz after an hour of uptime, that is an hour of dark tabs
with a clean log. The fix is Publisher.next_id(), fetch_add on
HostContext.id_addr, and the three producers take their ids from it.
The id is handed out rather than stamped inside publish, which would be
the stronger shape: it sits inside the frame body, and the framing is the
application's (format_sse_event puts id: first, Datastar event:
first), so the publisher cannot write it. Nor can publish check it: an
id at or below the word's current value is true of every correctly
numbered frame too, a handler having taken the next one meanwhile.
D27's publish shape now names the id space. Blobs' /stats keeps
steps as its own count and last_id as the id it last published.
A raising make crash-looped instead of refusing. With a probe
handler whose make raised, M0_WORKERS=2 logged five rapid crashes
and exited 1, printing Mojo's unhandled-exception trace five times; one
worker printed the listening banner and then the trace. serve now
catches a raising H.make, prints it under host: and exits 78, which
WorkerSupervisor reads as a configuration it must not respawn.
The producer's make gets the same, and one judgement call came with
it. D27's first form built the producer on its own thread, which starts
just before the serve, so a refusal from there was "before it serves" in
practice and not by construction. It is now built on the spawning
thread, inside ProducerThread.start, and moved into memory the thread
takes as its first act; a raise propagates out of start with no thread
spawned, and serve exits 78 before the server listens. The thread owns
the producer from its first step to the last and destroys it, as before.
test_host.mojo tells the two apart: a raising step ends the thread with
STATUS_RAISED, a raising make leaves it at STATUS_NEVER_RAN.
A third thing followed from the second. Only worker 0 builds a producer,
so under M0_WORKERS=2 its refusal left worker 1 serving: the
supervisor's rule was to let the others finish and exit 78 once they
had, which for m0serve, where every worker refuses the same thing, was
the same as ending them. For the host it was a server with no worker 0
and no producer, indefinitely. The supervisor now forwards SIGTERM to the
siblings on an EX_CONFIG exit and exits 78 once they are gone;
test_respawn.mojo pins it and D30 records the three together.
What the refusal uncovered, on its first CI run. smoke-todo's
two-worker phase went red on the macOS runner: datastar_todo's make
raised database is locked in one worker, and the host refused it with
78. Before this round that worker crashed on the unhandled exception and
the supervisor respawned it, and the second attempt won the race, so the
gate was green while every two-worker start was losing a coin toss.
Reproduced on an M4 at 29 of 30 starts. The locked step was not the
schema creation but open() itself: two processes switching one fresh
file out of the rollback journal race on PRAGMA journal_mode=WAL, and
SQLite answers the loser's at once without consulting the busy handler
set the line before. m0-sqlite's open now retries the switch on
SQLITE_BUSY within the same budget (O1, test_two_processes_open_one_ fresh_database, which fails on the old open in round 0), and the todo
app takes its schema lock with BEGIN IMMEDIATE for the second race the
busy handler cannot help. Gating an ungated row keeps finding real
defects; this one was found by a refusal replacing a crash.
Gates. smoke-host gained two phases (SPEC E25). The respawn phase
holds four streams spanning both workers for 4 s, SIGKILLs the pid the
beats name, and requires the streams still held on worker 1 to beat
again within 2 s, from the respawned producer, with an id above their
last; the hold is longer than the bound on purpose, since the broken
host's silence is the hold plus the respawn. A fresh stream beating is
not the assertion, because that passes on the broken host. The refusal
phase runs M0_HOSTCHECK_MAKE_RAISES=1 and
M0_HOSTCHECK_PRODUCER_RAISES=1 at one worker and two, each of which
must exit 78 naming the knob with no respawn or crash line and no worker
left. Six sabotages join the list, thirty-one in all: the id word made
after the fork, next_id not advancing it, the handler's raise left to
propagate, the producer's exiting 1, start swallowing it, and the
siblings left serving; the last runs against test_respawn.mojo, a
gate of its own.
Recorded, not fixed. The drain and the producer join run in
sequence, 5 s of DRAIN_TIMEOUT_NS and then 5 s of JOIN_TIMEOUT_NS,
so a held stream beside an overrunning step can reach docker stop's
10 s default grace. The smoke measures 5 s today only because nothing is
held during the overrun arm. It is a ROADMAP known issue, retired by
signalling the producer to stop when the drain begins so the two bounds
overlap.
Next
The host has nothing left to take from m0serve that both can use. What it does not offer, pool lanes, loops on threads and exec'd workers, is D29's list of retiring conditions, each waiting on an application that needs it.
Since: the pool lane landed on 2026-09-17 (D31, SPEC E26), for the ramp test, and the drain-then-join issue recorded above was retired with it; the-ramp-test is the record. Step 1's list of refused variables and "What v1 does not do" above are kept as written.