# The Mojo host — built 2026-09-16 > A design note from the engineering record. What a Mojo application's > `main` used to be, what the host (`lightbug_http.host` as written, > `m0_host` since 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: ```mojo 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: 1. It refuses `M0_THREADS`, `M0_BLOCKING_THREADS`, `M0_SPAWN_WORKERS` and `M0_WORKERS=0` with exit 78, before anything is bound. 2. It listens once, in the process that will fork. 3. 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. 4. It creates the bus unconditionally, at one worker too. 5. It creates the accept-share channels above one worker, unless `M0_ACCEPT_SHARE=0` asks for the bare race. 6. It forks when `M0_WORKERS > 1`. 7. It binds this worker's accept share. 8. It arms the signals, after the fork. 9. It builds this worker's handler with `H.make`. 10. On worker 0 alone, it starts the producer. 11. It serves, with this worker's bus channel drained. 12. 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`. 13. 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.** `step` returns 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 is `return period`. `blobs`' pause with nobody watching is `return PAUSE_POLL_NS` without 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 from `step` beat a separate `period_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_NS` from `mojo_pool.mojo`. After that the host prints `host: abandoned a producer step still running 5 s after the drain; exiting without it` and calls `_exit(0)`. `pthread_join` has no timeout, so waiting longer only turns SIGTERM into a no-op until `docker stop` sends SIGKILL. `ThreadSet.join_within` waits 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 as `False`. Blobs counts refusals in `/stats` as 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 inside `m0_http` is a different one: > [the-host-leaves-the-fork](https://m0serve.dev/notes/the-host-leaves-the-fork.md). 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 `.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](https://m0serve.dev/notes/flags-and-a-doctor-for-the-host.md). 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 `/stats` says 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, `/stats` must 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 on `apps/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 `_exit` after an abandoned producer. A forked worker leaves through `exit_worker` anyway, and a single process returning from `main` with the producer asleep in its step exited 0 in the same time. The `_exit` stays, 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 run` prints `error: execution exited with a non-zero result` for 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: 1. Worker A renders the list. 2. Worker B commits a change and renders a list that includes it. 3. B numbers its frame 5. 4. 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's `ViewService`, so a `Views` table 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_FD` and `M0_SHARED_ID_ADDR` exported; `required` is m0serve's `--spawn-workers`, where an anonymous page is no page at all; - `prefork_bus(channels)` and `prefork_accept_share(workers)`: created, kept across exec and exported, or adopted; - `bind_accept_share`, `shared_id_addr`, `spawned_worker_index` and `int_list_env`, moved from `m0serve.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 `m0pub` does. - **A spawned worker with no page descriptor is refused.** m0serve's old path skipped the adoption silently when `M0_SHARED_ID_FD` was missing and went on to bind accept sharing to the address its parent exported, which in a fresh image is nothing. `prefork_page` raises there, and `host_refusal` refuses an inherited `M0_WORKER_SPAWNED` outright, 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](https://m0serve.dev/notes/the-ramp-test.md) is the record. Step 1's list of refused variables and "What v1 does not do" above are kept as written.