# Row 106 (#1600) round 1: bus host lock follow-ups Brief: docs/plans/2026-10-13_landing-follow-ups.md, section "Bus host lock: release on a late throw, a vanishing lock, the third racer" (last changed in aedc5db8f; blob 001cba498 at f00613106). BASE: 0069949ffc78fa8d4998243a81fd7efe85279442. That is row 104's landed commit. I built on f00613106 and moved the candidate onto BASE before the gate, as Sage asked. The only commit between the two is row 104's, which touches none of the 5 manifest paths, so the diff is byte-identical. The gate and the mutants ran on BASE. Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 5 files, and applying the patch to a fresh worktree at BASE reproduces every digest in it. Per Sage, the third-racer sentence is in packages/cli/README.md, and packages/seat/README.md is untouched. ## What changed - packages/cli/src/host.mjs, `startHost` (Sage N1). The broker's fork, its boot send and the wait for its reply now all run in one `try`. Its failure path kills the broker if the fork returned one, waits for it to end, and calls `release()`. Before, the fork and the send ran outside the `try`, so a throw from either left `host.lock` behind until a later start took it over as stale. The wait now starts after the send rather than before it. A send that throws therefore leaves no pending wait whose rejection nobody handles. The reply and the exit event come on a later tick, so the wait still misses neither. The notifier's fork and send are unchanged; the brief covers only the time before the broker's reply. - packages/cli/src/host.mjs, `readState` (Dewey a). An ENOENT from the read returns null, which `claimHostLock` already treats as "try again" within `CLAIM_TRIES`. Any other read or parse failure still refuses with `bus host state is unreadable: `. A new `found` hook runs between the existence check and the read, for the tests only. `claimHostLock` takes `found` next to `between` and passes it on. `readHostState` and `release()` read through `readState` too: a `host.json` that goes between the check and the read now counts as no host, and `release()` already ignored a lock it couldn't read. - packages/harness/tests/git-tools.test.mjs, the `have` order test (Dewey b). It makes the two clone directories, reads their order with `readdirSync`, and gives the first-listed clone the older refs. It checks that the order holds once the clones exist, then expects the first-listed clone's 20 refs and then 12 of the other's. The old test took the order from the request, so it passed whatever clone order the code used. - packages/cli/README.md, step 3 of the host start (Dewey c). One new sentence: of three starts racing one stale lock, two can both claim it, and then the broker's `writer.lock` refuses the second broker. - roles/reviewer.md (Dewey d). The `nothing-to-fetch` line wraps after "has the commit;" like its neighbours. The words are the same. ## Tests | File | Base | Candidate | New | |---|---|---|---| | packages/cli/tests/host.test.mjs | 28 | 31 | `a broker fork that throws leaves no host.lock and no host.json`, `a broker send that throws ...` (one loop), `a host.lock that goes between the check and the read is tried again; one that can't be parsed still refuses as unreadable` | | packages/harness/tests/git-tools.test.mjs | 4 | 4 | the `have` order test, rewritten | The fork and send tests use a test helper, `throwOn`. It hooks the `send` property that node installs on each new child, which `spySends` and `failSends` in the same file already do. Throwing from that install makes `fork` itself throw, after the child has started; throwing from the installed `send` makes the boot send throw. The helper kills every child it saw after the test, the one the throwing fork started included. The ENOENT test writes a stale lock, and the `found` hook removes it the first time it runs. The claim then succeeds on the next try, and the hook ran once. The same test then writes `{not json` as the lock and expects the unreadable refusal, with the file left as it was. ## Mutants mutants.py and gate.sh are the scripts I ran, copied here; they point at my scratch worktree, so change `WT`/`G` to rerun them. BASE_TEST in mutants.py reads the test file from the worktree's HEAD, which must be BASE. mutants.py runs each mutant against the named tests only, through `--test-name-pattern`, and counts a pattern that matches no test as a fault. Each file is restored from its `.orig`, and the candidate's diff hashed 60246b4202f67281 (first 16 hex) before and after. Run 22:49:00 to 22:49:03Z on 2026-10-13. | Id | Mutant | Test | Result | |---|---|---|---| | n01 | the fork runs before the `try` | fork throws | killed | | n02 | the send runs between two `try`s | send throws | killed | | n03 | fork and send both before the `try`, as at BASE | fork throws, send throws | killed | | n04 | no ENOENT case in `readState` | lock goes between check and read | killed | | n05 | every read failure counts as no lock | lock goes between check and read | killed | | n06 | `have` takes the clones in reverse directory order | `have` order | killed | | n07 | `have` sorts the working refs by date across clones | `have` order | killed | | x01 | control: BASE's `have` test against n06 | `have` order | survived, as expected | | x02 | control: BASE's `have` test against n07 | `have` order | survived, as expected | Every run matched exactly 1 test, and every kill is an assertion failure. In n01 to n03, `host.lock` is still there after the throw. n04 refuses with `bus host state is unreadable` on the lock that went away. n05 is the check that an unparseable lock still refuses: with every read failure as null, the `{not json` claim loops to "kept changing" instead. The controls are expected to survive. They correct part of the brief's finding b. The brief says the old test tells per-clone order from a cross-clone sort only when `alpha` lists first. On this ext4, `alpha` lists first, and the old test still passes both n06 and n07. It reads which clone came first from `req.have[0]` and builds its expectation from that, so any order of whole clones passes. The new test fixes the order before the request. ## Gate gate.sh, on BASE 0069949ff, 22:43:03 to 22:48:53Z on 2026-10-13, DOCKER_HOST unset, Docker 29.7.2. | Step | Result | Time (UTC) | |---|---|---| | pkg-bus | 128 passed, 0 failed | 22:43:03-22:43:06 | | pkg-business | 67 passed, 0 failed | 22:43:06-22:43:09 | | pkg-cli | 168 passed, 0 failed | 22:43:09-22:43:33 | | pkg-control-board | 124 passed, 0 failed | 22:43:33-22:43:34 | | pkg-conversation | 182 passed, 0 failed | 22:43:34-22:44:24 | | pkg-discord | 178 passed, 0 failed | 22:44:24-22:44:27 | | pkg-harness | 73 passed, 0 failed | 22:44:27-22:44:37 | | pkg-ledger | 78 passed, 0 failed | 22:44:37-22:44:58 | | pkg-mosaic | 69 passed, 0 failed | 22:44:58-22:45:00 | | pkg-queue | 156 passed, 0 failed | 22:45:00-22:45:34 | | pkg-runs | 43 passed, 0 failed | 22:45:34-22:45:35 | | pkg-seat | 119 passed, 0 failed | 22:45:35-22:45:40 | | pkg-tasks | 92 passed, 0 failed | 22:45:40-22:45:45 | | pkg-webui | 108 passed, 0 failed | 22:45:45-22:46:41 | | script-auth | 15 passed, 0 failed | 22:46:41-22:46:42 | | script-conductor | 17 passed, 0 failed | 22:46:42-22:46:47 | | script-config | 24 passed, 0 failed | 22:46:47-22:46:49 | | script-discord | 66 passed, 0 failed | 22:46:49-22:46:56 | | script-extension-package | 18 passed, 0 failed | 22:46:56-22:46:57 | | script-foundation | 44 passed, 0 failed | 22:46:57-22:47:38 | | script-queue | 28 passed, 0 failed (node 156 passed) | 22:47:38-22:48:12 | | script-release | 14 passed, 0 failed | 22:48:12-22:48:24 | | script-task | 98 passed, 0 failed | 22:48:24-22:48:53 | The gate printed GATE_PASSED at 22:48:53Z. The Docker steps left a `wt_default` network, created at 22:48:12Z with 0 containers; I removed it. ## Choices and limits - The wait for the boot reply now starts after the send. The other order would need a `.catch` on a wait that a throwing send abandons. Node emits the child's `message` and `exit` events from the event loop, and the wait attaches its listeners in the same synchronous run as the send, so nothing comes in between. - A fork that throws after node has started the child returns no handle, so `startHost` can't kill that child. The test kills it. I don't know of a real path where node's `fork` throws after the spawn; this is what the hook produces. - An asynchronous send failure, an `error` event on the broker child, still has no listener in `startHost` and would end the process before `release()`. That was so at BASE, and the brief names only throws. A bounded follow-up if wanted. - The README sentence goes in step 3, after the takeover it qualifies. The existing sentence after it is unchanged. ## Going live (decision 109) The host change is live only with a new copy, a re-pin and a bus restart, all under Mos's go. Until then the live bus, Console and CLI run host.mjs from the pinned copy at 465bbdfbb. The tests and the README change nothing that runs. The launcher reads a role's contract at each launch, from `MOSAIC_ROLES_DIR` or else the `roles/` of the copy the bus runs from (packages/cli/src/config.mjs, `rolesDir`). So the reviewer wrap reaches a reviewer session only once that copy has it, and it changes the line breaks in the prompt only.