# The ramp test: lanes in the host, one module on two hosts — 2026-09-17 > A design note from the engineering record. Phase 3 of the Mojo host > plan, in two rounds: **R5a**, pool lanes in the host and a route's > placement on its table (this note's first half, SPEC E26 and N19, D31 > and D32); **R5b**, the ramp module itself, one views module compiled > under `m0serve` and under the host and gated byte for byte and by > placement (the second half, SPEC N20). The brief it executes is the > owner's `mojo-host-phase-3-brief.md`; its open questions are answered > below as recommended there. **The question.** The Mojo host (D28–D30) served every request on its loop. `m0serve` serves a Mojo mount on `MojoPool` threads, N per lane. A views module written for one could not be promised to behave on the other: the same 200 ms compute view that a mount absorbs on one of four threads stalls every connection on a host worker for 200 ms — the pool bullet's measured 1.6 ms to ~194 ms p99. The plan's Phase 3 asks for a module that compiles into both and answers the same bytes AND the same placement, and the placement half is what forces lanes into the host. ## R5a — the lane ### What the host does now under `M0_BLOCKING_THREADS=N` The variable v1 refused (D29) is served (D31): one `OffloadPool` per worker's loop, one lane, N `MojoPool` threads on it. The lane is marked GIL-free (`set_lane_gil_free`) because nothing in a Mojo host ever attaches to an interpreter: a job that has waited past the idle spin wakes a parked sibling, while `submit` stays elastic (SPEC M25's rule, which held a compute route to one of four threads before it and cost a trivial one a fifth of its rate under eager wakes). **One conformance, two instances.** `MojoPool.start[T]` asks for a `PoolHandler` built by `make(PoolContext)` on the thread that will use it; the application wrote an `AppHandler` built by `make(HostContext)`. `PoolLane[H]` is the adapter between them, in `host.mojo`: `ctx.user` is the address of the worker's `HostContext`, copied to memory that outlives `serve` exactly as the producer's is, and the copy handed to `H.make` carries `thread`, the pool thread's index, where the loop's own instance has `-1`. Only `func`, `before_request` and `after_response` are forwarded, because only those run on a pool thread; the streaming hooks, `tick` and `ws_message` are the loop instance's. The brief proposed the application conforming twice; the adapter was chosen because the host's reason to exist is that an application cannot spell the wiring wrong, and a second conformance is a second place to. **Every thread's handler, or none.** A `PoolHandler.make` that raised used to print `mojo-pool[i] raised` and leave the pool serving with one thread fewer — an existing gap under `m0serve`, and a contradiction of D30 the day the host took a lane. The brief's second option is built: `_pool_serve` sets `BLK_READY` the instant `T.make` returns, and `MojoPool.wait_ready` polls it, counting a thread that has already ended `STATUS_RAISED` at once rather than waiting for it. The host calls it before it serves and exits 78 if any thread is short. `PoolLane.make` prints the error under `host:` with the thread named before re-raising, so the line the smoke greps carries the application's own message. Measured with `M0_HOSTCHECK_POOL_MAKE_RAISES=1`: exit 78 at one worker and at two, where the supervisor ends worker 1 for it (D30's third rule); before the wait, the same knob served, two threads short. **What a stream does under a pool.** `_pool_serve` refuses a streaming response from a pool thread with 409, because the pool thread's own registries are ones nothing drains. That refusal stands, per request and named in the log, and `apps/host_check`'s gate pins it on `/events` rather than moving the route: the refusal is what an application meets. A stream open belongs on the loop, and there are two ways to put it there. A handler of its own answers it in `before_request`, as `host_check` now answers `/health`. A `Views` table flags the route. ### Placement is a property of the route (N19, D32) `add_read` and `add_write` take `on_loop=True`. The route stays in the main router — one `Allow`, one `url_for`, one 404 — and is registered in the loop router too, with which table and slot its view lives in (`LOOP_READ`, `LOOP_WRITE`; `add_loop`'s entries are `LOOP_STATELESS`). `answer_on_loop(req, state)`, the new overload, answers all three on the loop; `ViewsApp.before_request` and `ViewService.before_request` call it with their own state. A view that raises there is answered 500 and named, because the hook cannot raise. The stateless `answer_on_loop(req)` — a handler with no state at hand — declines an `on_loop` route, and `dispatch` answers it one round trip later, bytes identical. The brief's first open question, a flag or a second table: the flag. `Views` had two routers already (the loop's is a scan of one or two routes, so `before_request` does not route the whole table twice); a third would be a third `Allow` to merge and a second `url_for`. **The `add_loop` divergence on `m0serve` is recorded, not built** (D32, the brief's option ii). `WSGIHandler` is the loop handler there and holds no Mojo table, so a mounted table's loop routes are answered on a pool thread by `dispatch` — one round trip later, with that thread's state. The divergence is latency under a full lane, not a stall: the lane's other threads answer, and R5b measures it on both hosts. Option (i), a `thin` pointer plus an address from one extra `MojoMount` built on the loop thread, is the retiring condition, to be built on that measurement rather than argued. ### The shutdown bounds overlap Round 4 recorded a ROADMAP issue: the drain's 5 s and then the producer join's 5 s, in sequence. Building the lane would have added a third. The brief asked for the producer to be signalled when the drain begins; the mechanism is `run_event_loop`'s `stop_addr`, the address of a word the loop stores `perf_counter_ns()` into as the first act of `_shutdown_begin`. The host passes the producer's stop word (`ProducerThread.stop_addr`, valid before `start` and for a producer never started, since the block is the thread set's), so the producer is stopping DURING the drain, and `stop_and_join` reads the stamp back and waits only for what is left of the bound. The pool is pilled after the loop returns — a pill read beside a job still on the ring strands the job, which is why `m0serve` pills there too — and joined within the same remainder, floored at `POOL_JOIN_FLOOR_NS` (500 ms) so an idle thread taking its pill under a loaded runner is not counted a straggler. One correction to the issue as recorded. It said a HELD STREAM beside an overrunning step reaches 10 s. It does not: `_shutdown_begin` sends every held stream its farewell and closes it before the drain waits on anything. What stretches the drain is a request in flight, and the gate holds one: | shape | leaves in | |---|---| | a 4 s `/slow` in flight on a pool thread at SIGTERM, beside a 60 s step, stop word unstamped (the round-4 order) | 8.54 s | | the same, stamped as the drain begins | 5.05 s | Both answered the request whole. Without a pool the shape cannot overlap by construction — the loop reads the shutdown pipe only after the handler returns — which is another reason a blocking view belongs on a lane. ### The gates `smoke-host` (every PR) gains four phases, all on `apps/host_check`, which gained `/slow?ms=N` (a spin in `func`), `/health` answered in `before_request`, and the pool knob: - **placement** — `M0_BLOCKING_THREADS=2`, two connections looping a 200 ms spin holding both threads, the worst of 24 `/health` samples under 100 ms (0 ms on an M4); two streams opened on the loop carrying every beat under the pool, and four spanning two pooled workers; `/events-from-func` answered 409 with the refusal in the log; a clean exit with no thread reported a straggler; - **the negative arm** — `M0_BLOCKING_THREADS=0` under the same load must reach 100 ms (389 ms measured), or the placement arm proves nothing; - **overlap** — the table above, asserted as an exit inside 6 s with the request answered whole and the producer named; - **a raising pool `make`** — exit 78 at one worker and two, the knob named on the `host:` line, no crash line, no worker left. The refusal phase drops `M0_BLOCKING_THREADS=2` from its list. Recorded: `host.pool_health_ms` and `host.overlap_exit_s`. The unit halves: `test_host.mojo` (the lane builds the handler for its thread; a raising lane `make` is counted; a stop stamped 900 ms ago leaves 100 ms of a 1 s bound, against the unstamped control that waits the whole bound), `test_mojo_pool.mojo` (`wait_ready` on a sound pool and a refusing one), `test_views.mojo` (the flag, the stateless decline, the 500, `ViewService`). `sabotage-host` grows by seven rules to thirty-eight, and three moved. "The producer is never told to stop" now edits TWO files — the loop's stamp and the join's fallback — because removing either alone leaves the other telling it; the harness takes a tuple of paths for that. The seven: the variable served on the loop, the stop word withheld from the loop, the producer's join counted from the loop's return, a raising pool `make` served short, a pool thread's handler built as the loop's own, the gate app's `/health` moved onto the pool, and an on-loop read answered without its state (against `test_views.mojo`, a unit gate, since `views.mojo` is `src/`). ### Found on the way `listener.socket.fd` as a call argument is a value, and the expression that reads it was the listener's last use: Mojo destroyed it there, its destructor closed the socket, and the loop's first `fcntl` on the fd failed with EBADF before a connection was taken — with and without a pool. `Server.serve_nonblocking` never had the problem because the listener was its argument. `_run_loop` in `host.mojo` takes it as a read parameter for the same reason, and says so. ### Found by review The round's review, from a fresh context as the process asks, read the commit before the ramp was written and found one defect and two gates that could not tell. The defect: `pool`'s last use in `serve` was the join, so Mojo destroyed the `OffloadPool` there — while a straggler thread the join had given up on still held its address and would write its completion into freed memory when its view returned, in the microseconds before `_exit`. `m0serve` pins its own pool with `_ = pool.capacity` at the end of `_serve_offloaded` for exactly this; the host now does the same, and the harness records why no sabotage can guard it (no symptom on the wire). The gates: the placement phase held two threads of four, so an idle thread would have answered a `/health` that became a job within a millisecond and the phase could not tell "on the loop" from "on a spare thread" — it now holds every thread (a pool of two, both busy; two rather than four so a shared runner's cores keep something for the loop), and a sabotage moves the gate app's `/health` into `func` and insists the phase fails; and E23's prose still said the producer was told to stop after the drain. Two more things the review asked for: a stream opened on the LOOP under a pool is now gated (`host_check`'s `/events` moved into `before_request`, the refused shape kept as `/events-from-func`, and a two-worker pooled phase holds streams spanning both), and the ready wait says which of two things it refused — a raise, or a build still running at a ten-minute bound. ## R5b — the ramp ### The module `apps/ramp/views.mojo` is one `Views[Ramp]` table, four views and a state type, and nothing that knows which host it is on. Under `Mount`: `/` (the index, two links through `st.at.url_for`, `on_loop=True`), `/now` (`add_loop`, the timed route), `/search?sel=&k=` (the demo mount's filtered scan, a writing view over the instance's own score buffer) and `/slow?ms=` (a spin). Two adapters, each a page long: - `apps/ramp/mount/m0serve_mount.mojo` is the `MojoMount` `m0serve` builds in with `M0SERVE_MOUNT_DIR=apps/ramp/mount`, taking its prefix from the lane (`PoolContext.prefix`); `build-serve` gained `M0SERVE_INCLUDE`, the application's own module root, placed AFTER the mount directory so it can never be a second mount root (N14's rule). - `apps/ramp/server.mojo` is `serve[ViewsApp[Ramp]](AppConfig())`; the `ViewState` conformance sits on the state itself, and the host, having no mount table, takes the prefix from the module's `RAMP_PREFIX`. The smoke passes that same value to `--mount`, and a disagreement is a dead link the gate follows. The module keeps two rules the brief set. Nothing in a body names where it ran — the demo mount's `"thread":N` would be a lane index on one host and -1 on the other — and the spin count is not in `/slow`'s body either. And the state is built from the prefix alone (`Ramp.generate(at)`), so both adapters call one function. ### What the wire said `scripts/ramp_probe.py bytes` issues nine requests under the prefix to both ports — the index, `now`, the scan with and without parameters, `slow`, a 404 inside the prefix, a 405 with `Allow`, an `OPTIONS` 204 and a 405 on the writing route — and diffs status, headers and body per request. Every one was byte-identical apart from `Date` on the first run, as the brief predicted from the code: `x-worker` is set on holds alone, `x-thread` under `--threads` alone, and both hosts write the rest through the same `_finish_response`. It then follows the index's two links on each host and requires the same link without its prefix to be 404 on both. Outside the prefix the two hosts differ by design and are asserted per host: `/` is the Python application on `m0serve` and the table's problem+json 404 on the host, and `/xapp/now` reaches neither mount. Placement, `scripts/ramp_probe.py placement`, is `host_probe.py`'s arithmetic under the prefix: K connections looping `/x/slow?ms=200`, 24 samples of `/x/now` at random gaps, the worst reported. | arm | m0serve (lane of 2) | host (lane of 2) | host, bare loop | |---|---|---|---| | one connection on `/x/slow` | 1 ms | 0 ms | 385 ms | | two connections — the lane full | 196 ms | 0 ms | — | (The first cut used lanes of four with two and four loaders: 1 ms and 1 ms, then 154 ms against 5 ms — the same shape. Two threads leave a shared runner's cores something for the loop.) The first row is the gate's bound on both hosts (100 ms, `sim_loop`'s), and the bare loop is the negative arm that must fail it. The second row is the measurement D32 asked for. With every thread busy, `m0serve`'s loop route waits for a pool thread — its loop handler holds no Mojo table — while the host's, an `add_loop` route, is answered by the loop: a fixed cost of about one spin on `m0serve`, no stall. The gate bounds the host there and RECORDS `m0serve` (`ramp.full_lane_now_ms.m0serve`), so the number that would justify building option (i) accumulates on every run instead of being argued once. ### The gates `smoke-ramp` (every PR, SPEC N20) builds both binaries from the one module, starts `m0serve` with the mount beside `bareapp.wsgi` and the host with a lane of four on ports of its own, runs the bytes phase, the two-loader placement on each, the full-lane arm on each, then the bare host as the negative arm, and records the four placement numbers. It reaps by its own temp directory as every host smoke does. ### Open questions, answered 1. A flag on `Views`, not a second table (N19). 2. The drain-then-join issue retired inside R5a, which added the third bound it would have stacked. 3. `add_loop` on `m0serve`: recorded now (D32), built on evidence; the evidence is now recorded on every run. 4. `apps/ramp` is a fresh module; the demo mount keeps its job and its thread index.