Bus host lock follow-ups: release on a fork or send throw, retry a lock that vanishes before the read, the have order test fixed to directory order, the third-racer README sentence and the reviewer wrap. Gate passed on 0069949ff; 9 mutant runs as expected. Co-Authored-By: Claude Opus 5.5 <[email protected]>
9.2 KiB
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: 0069949ffc. 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 onetry. Its failure path kills the broker if the fork returned one, waits for it to end, and callsrelease(). Before, the fork and the send ran outside thetry, so a throw from either lefthost.lockbehind 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, whichclaimHostLockalready treats as "try again" withinCLAIM_TRIES. Any other read or parse failure still refuses withbus host state is unreadable: <file>. A newfoundhook runs between the existence check and the read, for the tests only.claimHostLocktakesfoundnext tobetweenand passes it on.readHostStateandrelease()read throughreadStatetoo: ahost.jsonthat goes between the check and the read now counts as no host, andrelease()already ignored a lock it couldn't read. - packages/harness/tests/git-tools.test.mjs, the
haveorder test (Dewey b). It makes the two clone directories, reads their order withreaddirSync, 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.lockrefuses the second broker. - roles/reviewer.md (Dewey d). The
nothing-to-fetchline 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 trys |
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
.catchon a wait that a throwing send abandons. Node emits the child'smessageandexitevents 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
startHostcan't kill that child. The test kills it. I don't know of a real path where node'sforkthrows after the spawn; this is what the hook produces. - An asynchronous send failure, an
errorevent on the broker child, still has no listener instartHostand would end the process beforerelease(). 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.