@tikoci/quickchr 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -62,6 +62,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
62
62
 
63
63
  **Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)
64
64
 
65
+ 11. **A download is bounded by whether it is *moving*, not by a flat total** — `src/lib/download.ts` is the one download path for both `images.ts` and `packages.ts`. Before it, each caller had its own bound and they disagreed: images aborted at a flat `120_000` per attempt with three retries, packages had **no** deadline and **no** retries, so its only bound was whatever the calling test imposed.
66
+
67
+ A total-duration deadline cannot distinguish *slow* from *stuck*. It fires on a healthy transfer whose only sin is being large, and the retry path then re-downloads from zero — one slow transfer becomes three.
68
+
69
+ **The deadline sat inside the natural variance of a healthy transfer, which is why it was intermittent.** Measured locally 2026-07-31 against `download.mikrotik.com` — same 52.2 MB `all_packages-arm64-7.22.1.zip`, same link, same client, four consecutive attempts:
70
+
71
+ | attempt | elapsed | throughput | under the old flat 120 s? |
72
+ |---|---|---|---|
73
+ | 1 | 118.4 s | 0.421 MB/s | yes, by 1.6 s |
74
+ | 2 | 94.2 s | 0.528 MB/s | yes |
75
+ | 3 | **123.5 s** | 0.403 MB/s | **no — aborted** |
76
+ | 4 | 82.2 s | 0.606 MB/s | yes |
77
+
78
+ All four completed. **One in four exceeded the deadline**, and a fifth attempt through the new streaming path measured 129.4 s (0.385 MB/s) — so the range on one ordinary link is 82–129 s against a 120 s bound. The deadline was inside the distribution, and an unchanged healthy download therefore passed or failed on link jitter alone.
79
+
80
+ That is a sharper statement of the defect than a clean abort would have been, and it is why CI saw "both images needed two retries" *sometimes* rather than always. CI's cold ~0.35 MB/s (→ ~149 s) sits **below** this whole local range, which is why a hosted runner hit it far more reliably than this laptop does — run 30606079288, and B7's local suite where `provisioning.test.ts` spent 619 s of 992 s downloading with zero QEMU processes alive.
81
+
82
+ ⚠️ **Do not restate this as "the old code always aborted that download."** An earlier draft of this entry said so on the strength of a single 129.4 s measurement; the next attempt completed in 101.5 s and disproved it as a general claim. One measurement of an intermittent is a signal, not a fact — the same rule the CI program runs on.
83
+
84
+ **Two deadlines, and the failure says which fired.** Neither alone is sufficient: stall detection alone would let a transfer trickling at one byte per second run forever, and a size-derived budget alone cannot fail a wedge quickly.
85
+ - **Stall** (`DOWNLOAD_STALL_MS`, 30 s) — reset on every received chunk. Bounds *silence*, so a moving transfer is never aborted for being slow.
86
+ - **Transfer budget** — `DOWNLOAD_BUDGET_BASE_MS` (30 s for connect/TLS/headers) plus `content-length ÷ COLD_DOWNLOAD_FLOOR_BYTES_PER_S`, a **floor** throughput of 120 000 B/s ≈ ⅓ of the slowest cold transfer ever measured. The 52.2 MB zip gets 465 s. When the server sends no `content-length` the stated `DOWNLOAD_NO_LENGTH_BUDGET_MS` (15 min) applies and the outcome says so — verified that `download.mikrotik.com` *does* send it on both image and package artifacts, so the size-derived budget is the production path.
87
+
88
+ **Retry policy follows from that split.** `DOWNLOAD_STALLED` is retriable — a wedged socket usually moves on the next attempt. `DOWNLOAD_TOO_SLOW` is **terminal on purpose**: the budget is already ~3× the slowest throughput on record, so retrying re-downloads from zero on a link measured slower than the floor, which is the exact behavior this decision removes. It is a signal that the floor is wrong (or the link genuinely is), not something to paper over with another attempt.
89
+
90
+ **The floor constant has one home.** It lives here and `test/integration/timeouts.ts` re-exports it; `coldDownloadTestTimeout()` is *derived from* `transferBudgetMs()` rather than recomputing it, which makes one of #106's partial orders (`download deadline < test timeout`) structural instead of coincidental. Two constants asked not to drift are how they drift.
91
+
92
+ **Streaming also bounds memory, which the old path did not.** `await response.arrayBuffer()` held the entire artifact in RAM before writing it — 52.2 MB for the largest. Measured: a `Bun.file().writer()` sink writing 40 MiB in 1 MiB chunks keeps the file growing on disk in lockstep with flat RSS (22 MB throughout), so the transient allocation is gone. Recorded as a side effect, **not** as a claim about #76 — a 52 MB transient is not a plausible cause of a runner losing communication, and B8a/B8b should not treat this as having changed that picture.
93
+
94
+ **Partial transfers cannot poison the cache.** Downloads stream to a **per-call** `<dest>.<pid>-<uuid>.part` and are renamed only after a verified transfer. Per-call, not a fixed `<dest>.part`, because both callers guard only with `existsSync(destPath)` — two concurrent downloads of one artifact (two `quickchr start`s, parallel tests) would otherwise interleave writes into one file, publish the corrupt result, and delete each other's in-flight file. The unique suffix removes the collision without deduplicating the work; coalescing concurrent downloads needs a lock at both call sites and is a larger change.
95
+
96
+ Verification is two checks, because either alone leaves a hole: bytes received must equal `content-length` **when one was sent**, and a **zero-byte body is always a failure** regardless of framing. The second exists because Bun's fetch hands back an *unknown-length* response for a malformed `content-length` — measured against a raw stub, `4096.5` and `-5` are passed through as headers but deliver 0 bytes, while a blank value is stripped to `null` — and an unknown-length response has no length to verify against. Without it the empty file would be renamed into the cache and served as a complete artifact forever. No artifact quickchr downloads is empty, so this costs nothing. Both callers gate on `existsSync(zipPath)`, so a truncated file left at the destination would be served as a complete cached artifact forever. Resuming with a `Range` request would be better than either deadline (MikroTik sends `accept-ranges: bytes`) and is deliberately out of scope — this decision bounds and classifies a transfer, it does not change the transport. Covered by `test/unit/download.test.ts`. → [#116](https://github.com/tikoci/quickchr/issues/116)
97
+
98
+ ⚠️ **Testing note.** `Bun.serve` **drops an explicit `content-length` when the body is a `ReadableStream`** and frames the response `transfer-encoding: chunked`, so a stub built on it cannot exercise the size-derived budget at all — every response looks unknown-length. The anchor tests use a raw `Bun.listen` HTTP stub for that reason.
99
+
65
100
  ## Port Layout
66
101
 
67
102
  | Offset | Service | Guest Port |
@@ -102,7 +137,10 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
102
137
  │ ├── serial.sock # Serial console
103
138
  │ ├── qga.sock # QGA (x86 only)
104
139
  │ ├── qemu.pid # PID file
105
- │ └── qemu.log # Output log
140
+ │ ├── qemu.log # Output log
141
+ │ └── serial.log # Serial console tee (only with QUICKCHR_SERIAL_LOG=1)
142
+ └── failures/ # Boot-failure reports (see below)
143
+ └── boot-failure-<machine>-<iso8601>.json
106
144
  ```
107
145
 
108
146
  **Global settings** live separately, under the XDG **config** tier (not the data tree
@@ -139,6 +177,14 @@ See MANUAL.md's CLI reference and environment-variables sections for the full su
139
177
  Full artifact map, dispatch recipes, and failure-diagnosis guide live in
140
178
  `.github/instructions/ci.instructions.md` — this section is the high-level rationale only.
141
179
 
180
+ **The toolchain is part of the repository (#148).** `.bun-version` pins the exact Bun that
181
+ CI, integration and release all install, enforced by `scripts/check-bun-pin.ts` in
182
+ `bun run check`. Before this, `setup-bun` was called with no version anywhere, so the tested
183
+ runtime could change between two identical commits — and did: a six-week gap with no `ci.yml`
184
+ runs spanned the Bun 1.4.0 release, and the next PR inherited a 1.3.14 → 1.4.2 jump as if it
185
+ were its own regression. Anything that can turn the build red is a dependency and belongs in
186
+ a reviewable diff; "whatever is newest at run time" is not a version.
187
+
142
188
  **The 2026-07 refactor (#29)** replaced the organically-grown scheme (integration on every
143
189
  PR push, three parallel copies of the runner logic, `continue-on-error` green-washing)
144
190
  with a layered design:
@@ -170,7 +216,9 @@ with a layered design:
170
216
  - **`release.yml`** — one-click publish from committed release state: freshness gate
171
217
  (no suite re-run — main is kept continuously release-able) + `package.json` version
172
218
  with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
173
- --provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes
219
+ --provenance` (odd minor → `next`, even → `latest`; a stable release is then also
220
+ tagged `next`, since there is no separately maintained pre-release line and `next`
221
+ must not point at something older than `latest`). CI never bumps versions or pushes
174
222
  to protected `main`.
175
223
  - **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
176
224
  record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
@@ -189,6 +237,33 @@ incidentally; crediting those suppressed the scheduler for versions no full suit
189
237
  targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
190
238
  rollup after fold-logic changes.
191
239
 
240
+ **What survives losing a runner (#76, #77)**: measured on run
241
+ [30665449265](https://github.com/tikoci/quickchr/actions/runs/30665449265), whose three
242
+ `macos-x86` legs stopped communicating mid-suite. This is the constraint every
243
+ lost-runner instrument has to be designed around, so it is recorded rather than
244
+ re-derived:
245
+
246
+ | Evidence | Survives? |
247
+ | --- | --- |
248
+ | Uploaded artifacts | **No** — `if: always()` steps never run |
249
+ | The archived job log | **No** — the blob returns `BlobNotFound`, even though a *normally failing* leg in the same run keeps its full log. Failure preserves the log; runner loss destroys it |
250
+ | The jobs API step ledger | **Yes** — the test step stays `in_progress` with a start time, later steps `pending`, and the job carries a `completed_at` |
251
+ | A check run PATCHed during the leg | **Yes** — it is server-side the moment it is written, and stays un-closed |
252
+
253
+ So a progress marker has to be *pushed out of the runner as it goes*; nothing collected
254
+ at the end can work. That is what `ci-leg-checkpoint.ts` (per-file check-run updates) and
255
+ `ci-leg-ledger.ts` (planned-vs-completed reconciliation into `ci-data/attempted-legs.json`)
256
+ implement. The ledger is a **separate file from `tested-versions.json` by decision**: the
257
+ version scheduler reads that file as a presence test, so recording an aborted run there
258
+ would make the version look tested and silently stop rescheduling it.
259
+
260
+ The same run also pins down #76's shape: the three legs died **62.0 / 64.1 / 65.0 minutes
261
+ into their own jobs**, staggered with their own start times across 14 minutes of
262
+ wall-clock. So the boundary is per-job elapsed, not a shared external event and not a
263
+ round 60-minute constant. Read `completed_at` as an **upper bound** on the wedge — it is
264
+ when the service gave up on a silent runner, and the interval between the runner going
265
+ quiet and that verdict is uncharacterized.
266
+
192
267
  **Merge policy**: squash-only, PR title → main commit subject (write PR titles as
193
268
  conventional commits), PR body → commit body, branches auto-delete. Review threads must
194
269
  be resolved before merge (`required_conversation_resolution`) and automated reviews must
@@ -204,6 +279,174 @@ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inpu
204
279
  - `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
205
280
  machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch
206
281
 
282
+ ### Boot-Failure Forensics (`src/lib/diagnostics.ts`)
283
+
284
+ Discovered constraint from the `ci:slow-platform-flake` series (#76 #79 #80 #91):
285
+ **a failure that cleans up after itself is unfixable.** Both `BOOT_TIMEOUT` paths
286
+ used to `stop()` + `remove()` the machine and embed `qemu.log.slice(-1200)`, which
287
+ on every recorded CI failure contained nothing but the cleanup SIGTERM. Four rules
288
+ came out of that:
289
+
290
+ 1. **`waitForBoot()` classifies, it does not just return `false`.** A boot that
291
+ never becomes REST-ready is several different bugs wearing the same error:
292
+ `refused` = QEMU never bound the port (host-side); `probe-timeout` = it bound
293
+ and nothing behind it answered (guest-side); `reset`/`wrong-body` = the guest
294
+ answered and RouterOS is still settling (the #69 shape). Collapsing all of
295
+ them to a boolean is why #79 stayed open across two full CI runs. The tally
296
+ rides on an optional `BootProbeStats` out-param, so normal boots pay nothing.
297
+
298
+ **Do not read a live host port as "the guest is up."** Under the default
299
+ `user` (slirp) network mode QEMU's hostfwd `listen()`s for the process's whole
300
+ lifetime and accepts before it tries to reach the guest. Verified locally
301
+ (2026-07-27) by starting CHR with `mem: 32`: 72/72 probes `probe-timeout`,
302
+ `hostfwd: accepting`, `info status: running`, empty `serial.log`. So
303
+ `refused` is much rarer than intuition suggests, and `probe-timeout` — not
304
+ `refused` — is the ordinary signature of a guest that never booted.
305
+
306
+ 2. **The report is written outside the machine directory**, to
307
+ `<dataDir>/failures/`. Every integration test wraps its body in
308
+ `finally { cleanupMachine(name) }` → `remove()`, which deletes the machine
309
+ dir. A report written next to `qemu.log` is gone before CI can upload it,
310
+ *regardless* of `QUICKCHR_PRESERVE_ON_FAILURE`. The report therefore embeds
311
+ log contents rather than pointing at them, and stands alone.
312
+
313
+ 3. **`QUICKCHR_SERIAL_LOG=1` is opt-in, and must stay opt-in.** It adds
314
+ `logfile=` to the serial chardev, which is the only way to see what RouterOS
315
+ printed on a boot that never reaches REST — the socket chardev keeps no
316
+ history, so connecting *after* the failure shows nothing. But serial-console
317
+ provisioning types the generated user password in cleartext, so the log is
318
+ secret-bearing. CI turns it on because CI passwords are per-run throwaways.
319
+
320
+ 4. **Timeouts report where they stalled.** `monitorCommand()` records
321
+ connect → first byte → prompt → command written → response first byte, and
322
+ renders them into the timeout message. "Monitor command timed out" alone
323
+ cannot distinguish #80's hypotheses (monitor never greeted us vs. QEMU took
324
+ the command and went quiet); the phase line can, at the cost of a few
325
+ `Date.now()` calls on the success path.
326
+
327
+ Two more came out of #105/#106, once the reports above proved only *that* #79's
328
+ boot went silent:
329
+
330
+ 5. **Evidence has to localize, so the capture is layered by invasiveness.**
331
+ Host-side per-port classification from `info usernet` (read-only, always on)
332
+ → read-only guest snapshot over the serial console, which stays reachable
333
+ while REST is dead (on whenever a serial channel exists) → the counting-rule
334
+ probe, which writes a mangle rule into the guest and is therefore opt-in via
335
+ `QUICKCHR_DEEP_BOOT_DIAGNOSTICS=1` and skipped under
336
+ `QUICKCHR_PRESERVE_ON_FAILURE=1`. Each layer answers a question the one
337
+ before it cannot: `SYN_SENT` proves a silent drop but not *whose*; only a
338
+ counter inside the guest separates "RouterOS dropped it" from "it never
339
+ arrived". The RouterOS-side details (why mangle `passthrough` and not a
340
+ filter `accept`, why conntrack cannot answer this) live in
341
+ `provisioning.instructions.md`; the `info usernet` reading in
342
+ `qemu.instructions.md`.
343
+
344
+ The same secrecy rule as (3) applies one level up: guest payloads go to the
345
+ JSON report, and only credential-free shapes — row counts, booleans, a
346
+ verdict — reach the thrown error, which CI echoes into public job logs.
347
+
348
+ 6. **A timeout that outlives the forensics is not optional.** Integration tests
349
+ hardcoding `300_000` against a 480 s same-arch TCG budget meant bun killed
350
+ the test before `waitForBoot()` gave up, so the capture above never ran —
351
+ four #79 reproductions arrived bare for exactly this reason. Tests derive
352
+ their timeout from `defaultBootTimeout()` plus `BOOT_FORENSICS_BUDGET_MS`
353
+ (`test/integration/timeouts.ts`), and a unit test asserts the invariant
354
+ across every arch/accel combination. Note this is alignment, not tuning: the
355
+ 4× same-arch TCG factor is still ~11× the measured 44 s worst case, and
356
+ cutting it needs its own measurement of the package-install and device-mode
357
+ paths (#106).
358
+
359
+ Corollary for CI cache keys: `actions/cache` only saves when the primary key
360
+ *missed*, so the concrete resolved RouterOS version belongs in the key (#91).
361
+ The writable owner must prefetch and verify a declared image-and-package
362
+ manifest before saving; saving opportunistic test byproducts makes a red or
363
+ lost suite permanently cold, while saving only images makes immutable entries
364
+ permanently package-incomplete. Acquisition is therefore a named pre-test step,
365
+ restore-key extras are reconciled away before save so auto-prune cannot evict
366
+ fixed fixtures later, and a manifest change bumps the cache-key generation.
367
+ Raw images must have in-bounds DOS partition extents; package directories are
368
+ published atomically with an exact filename/size manifest. Tests boot the same
369
+ concrete pin named by the immutable key rather than resolving the channel again
370
+ (#144).
371
+
372
+ 7. **The boot probe can break the service it is waiting for.** `restGet()`
373
+ implements its deadline as `req.destroy()` — a mid-flight TCP teardown. On
374
+ its own that is harmless. But an aborted request **followed by a completed
375
+ one** corrupts RouterOS `www`: the guest then resets incoming connections,
376
+ and a surviving connection is answered with the status computed for the
377
+ *aborted* request. An `admin:` probe carrying a valid empty password comes
378
+ back `401`. `waitForBoot()` emits exactly that pattern — overrun a probe,
379
+ then succeed on the next one 2 s later.
380
+
381
+ Reproduced deterministically on RouterOS 7.21.5, x86/HVF **and** arm64
382
+ cross-arch TCG, with `curl` in a separate process confirming it is
383
+ guest-side rather than a Bun `node:http` artifact
384
+ (`test/lab/www-abort-damage/`). Two consequences for design: a slow-but-alive
385
+ REST layer must be **waited for** rather than aborted, and backing off after
386
+ consecutive aborts matters because it is the abort/complete *alternation*,
387
+ not the abort count, that does the damage. It also means a REST status
388
+ received after any timeout in the same conversation cannot be trusted to
389
+ belong to the request that received it — the #69 surface.
390
+
391
+ Bounds, so this is not over-read: locally `www` recovers in 1.3–2.4 s, so the
392
+ *permanent* wedge in #79's CI forensics is **not** reproduced, and neither is
393
+ whatever made CI's very first probe exceed 3 s — locally a failed login
394
+ answers in ~125 ms even at `cpu-load: 100`. Both remain open, and the entry
395
+ point to the CI failure is still the unexplained one.
396
+
397
+ 8. **Readiness is not the end of the evidence.** Items 1–7 all instrument a boot
398
+ that never became REST-ready. #69 is the other shape: the machine boots, it
399
+ passes `waitForBoot()`, and a *later* request is reset — so every instrument
400
+ above was skipped and the failure arrived as one line of `ECONNRESET`.
401
+ `captureRunningFailure()` (`quickchr.ts`) runs the same instrument set against
402
+ a machine that is still up, and does not stop, remove, or otherwise touch it —
403
+ the caller owns the lifecycle and may well keep testing against it.
404
+
405
+ What that capture has to add is a `trigger` (`FailureTrigger` in
406
+ `diagnostics.ts`): the failed operation, the error with its `code`/`errno`,
407
+ `sinceReadyMs`, and the credential transition that preceded it. Without it the
408
+ report is a healthy machine with no statement of what broke — a boot-failure
409
+ report explains itself through `restProbe`, and here `restProbe` is null. The
410
+ transition is **stated by the caller, never inferred**: the capture can see
411
+ the auth header, but "who this request authenticates as" and "what just
412
+ changed on the guest" are different facts, and only the caller knows the
413
+ second. An absent field reads as "not recorded", which is true; a guessed one
414
+ would send the next reader down a path nobody actually took.
415
+
416
+ The two report kinds share one directory and one 20-file cap, which is why
417
+ pruning orders by the timestamp *inside* the filename rather than by the
418
+ filename: whole-name sort was chronological only while every report carried
419
+ one prefix, and would now rank every `post-readiness-failure-` above every
420
+ `boot-failure-`, deleting the newest of one kind to keep the oldest of the
421
+ other.
422
+
423
+ Building that capture surfaced a defect in the instrument it reuses. The
424
+ guest snapshot's per-query budget was sized on the ~0.3 s cost of a command on
425
+ an already-open console session, which left the **~11.4 s serial login**
426
+ unbudgeted — and `bootFailureGuestExec()` then *divided* that budget across
427
+ credential candidates, so no attempt could finish logging in. No credential was
428
+ ever marked working, every later query repeated the same split, and the
429
+ snapshot spent its full 60 s to report `consoleReachable: false` about a guest
430
+ answering serial in 10 ms. It read as a broken console, which is why it went
431
+ unnoticed on the boot path: there the guest usually *is* unreachable, so the
432
+ wrong answer looked right. Queries now carry a login allowance until one of
433
+ them answers (`GUEST_LOGIN_ALLOWANCE_MS`), with the per-candidate floor raised
434
+ to the measured login cost; the 60 s snapshot budget and the 180 s forensics budget
435
+ are unchanged, so no test timeout moves. Locally this took a capture from
436
+ 61.1 s / 0 of 7 queries to 14.4 s / 7 of 7.
437
+
438
+ The general rule, since it is not obvious from the numbers: a console budget
439
+ has two parts that differ by ~40×, and only one of them is a round-trip.
440
+ `provisioning.instructions.md` has the phase-by-phase measurements.
441
+
442
+ Test-side, the same finding forces a single REST client:
443
+ `test/integration/chr-rest.ts` is the only way integration tests reach CHR.
444
+ Tests previously used Bun `fetch()` and, in one file, a hand-rolled `node:http`
445
+ copy of `restGet` — two extra connection-reuse policies that made "client or
446
+ guest?" unanswerable on every reset. Collapsing them is **confound removal,
447
+ not a fix**: run 30507484030 reset through `restGet` too, and
448
+ `test/lab/bun-pool/` never reproduced the pooling bug it was built to catch.
449
+
207
450
  ## Design Principles
208
451
 
209
452
  ### Scope Boundary — QEMU Expert, Not Orchestrator
@@ -230,6 +473,48 @@ The `default-route-distance=2` ensures the shared route is backup — SLiRP ethe
230
473
 
231
474
  **TCG hazard:** SLiRP half-open connections (TCP connect succeeds, data never flows) burn the full per-probe HTTP timeout in `waitForBoot`. Under cross-arch TCG where TCP round-trips are slow, this compounds badly. Lab: `test/lab/slirp-hostfwd/`.
232
475
 
476
+ #### NIC MAC Addresses — Stable Per Machine, Assigned Once
477
+
478
+ QEMU's default NIC address is `52:54:00:12:34:56`, incremented per NIC **within** a
479
+ guest. NIC index *N* therefore holds the same address in *every* guest. That is
480
+ harmless while machines only have `user` NICs (each SLiRP netdev is its own
481
+ namespace and nothing is shared), and fatal the moment two machines share an L2
482
+ segment — a named socket, a `socket:listen`/`socket:connect` pair, a TAP, or a
483
+ RouterOS bridge on a hub VM. Forwarding then half-works, and every instinct points
484
+ at RouterOS or the overlay rather than at the launcher (#154).
485
+
486
+ quickchr assigns each NIC a locally-administered address: `02:` (bit 1 set =
487
+ locally administered, bit 0 clear = unicast, so it can never collide with a
488
+ vendor-assigned address) followed by five octets of a SHA-256 over the machine
489
+ name, NIC index and a collision salt. Deriving rather than randomizing means a lab
490
+ rebuilt under the same machine names presents the same addresses. The address is
491
+ persisted in `machine.json` and `deriveMac()` is consulted only when a NIC has
492
+ none, so a later change to the derivation cannot move an existing machine's
493
+ addresses.
494
+
495
+ **Assigned at creation only — never backfilled onto an existing machine.**
496
+ RouterOS ties its persisted interface identity to the MAC. Change it and the guest
497
+ treats the NIC as new: the `ether1` holding the DHCP client is orphaned, no address
498
+ is obtained, and hostfwd accepts on the host side while every guest SYN is dropped
499
+ — the same half-open signature as the SLiRP/ether1 hazard above, from a different
500
+ cause.
501
+
502
+ Grounded by A/B against a live CHR (7.24.2 x86, HVF), same machine and same disk:
503
+
504
+ | MAC | Result |
505
+ |---|---|
506
+ | original | boots, REST ready |
507
+ | changed | boot fails — 36 probe-timeouts over 178 s, all forwarded ports `dropped` |
508
+ | restored | boots, REST ready |
509
+
510
+ So a machine created before MACs existed keeps QEMU's defaults for its lifetime and
511
+ must be recreated to get a stable address. Silently rewriting a working lab's L2
512
+ identity on upgrade would be the worse trade.
513
+
514
+ `deviceArgs()` in `network.ts` is the single place a `-device virtio-net-pci` is
515
+ emitted; `qemu.ts`'s fallback path builds its own `-netdev` strings but calls into
516
+ it, so a NIC cannot reach QEMU without its address.
517
+
233
518
  #### Host-Side L2 Capture (MNDP, MAC-Telnet)
234
519
 
235
520
  A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
@@ -252,7 +537,7 @@ capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
252
537
  The dual of `hostfwd`: SLIRP's gateway `10.0.2.2` *is* the host from inside the
253
538
  guest, so guest-originated UDP to `10.0.2.2:<port>` reaches a host socket bound on
254
539
  loopback `<port>` with **no forward and no extra NIC**. This generalizes the TZSP
255
- path (`tzspGatewayIp`/`captureInterface`) — it is not TZSP-specific and reaches an
540
+ path (`hostGatewayIp`/`captureInterface`) — it is not TZSP-specific and reaches an
256
541
  ordinary bound socket, not just a `tshark`/pcap capture. The host socket **must be
257
542
  unconnected**: SLIRP re-emits from a rewritten loopback source
258
543
  (`127.0.0.1:<ephemeral>`), which a `connect()`-ed socket would filter. This closed
@@ -313,6 +598,21 @@ Provisioning via REST API is preferred (simple HTTP calls). Serial console provi
313
598
 
314
599
  **Credential resolution** (`src/lib/auth.ts`): Priority order is (1) explicit `--user`/`--password` override, (2) provisioned user from `machine.json` (`state.user`), (3) CHR default `admin:` (empty password). Both `exec()` and `rest()` on ChrInstance use this.
315
600
 
601
+ **`machine.json` holds two kinds of fact, and `clean()` is where they diverge.**
602
+ Some fields describe the *guest* — what accounts exist on the disk right now:
603
+ `user`, `managedSshKey`, `disableAdmin`, and the per-instance entry in the secret
604
+ store. Others are *intent* — what to apply the next time provisioning runs:
605
+ `packages`, `deviceMode`, `secureLogin`. `clean()` replaces the disk with a fresh
606
+ image, so every guest fact it recorded is false the moment it returns, and nothing
607
+ restores them: a post-`clean()` `start()` reaches `_launchExisting(…, undefined)`
608
+ because `lastStartedAt` is set, so the erased account is never recreated. `clean()`
609
+ therefore clears the guest facts (and deletes the managed keypair under
610
+ `<machineDir>/ssh/`) and keeps the intent. Without that, credential resolution
611
+ kept preferring `state.user` and authenticated every REST call, exec, and SCP as a
612
+ user RouterOS had deleted — the confound removed ahead of #79's QEMU-version
613
+ experiment. Priority (3) above is what a cleaned machine must land on, and it is
614
+ the truth of a fresh CHR image: `admin` with an empty password.
615
+
316
616
  Output formatting via `--json` flag. RouterOS trick for structured output: wrap commands in `[:serialize to=json [<routeros-cmd>]]` to get JSON from any CLI command. For REST-to-CLI mapping, see tikoci/restraml `lookup.html`.
317
617
 
318
618
  ### Examples Philosophy
@@ -352,6 +652,307 @@ the CLI/library surface is tracked in
352
652
 
353
653
  A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`. The structured, versioned **descriptor v1** contract that centrs consumes for `--quickchr` targets — its shape, per-service semantics, scope boundaries, and staged implementation plan — is specified in [`docs/centrs-interface.md`](./docs/centrs-interface.md) (issue [#71](https://github.com/tikoci/quickchr/issues/71)).
354
654
 
655
+ ### Tips — pointing at the better path without becoming chrome
656
+
657
+ `quickchr exec` is a raw `/rest/execute` pipe: whatever the caller types is what RouterOS is asked to run, and quickchr has no opinion about whether the command is well-formed. centrs does — it checks a command's shape against the device's own command tree before running it and has per-path help — so `centrs --quickchr <name> <command>` is the better path for anything more than a one-liner. An agent that does not know this reads quickchr's source to find out, which is the discoverability failure described above, one layer up.
658
+
659
+ The answer is a **tip**: one dim line, at the moment the better path applies. Three rules keep tips from turning into noise (`src/cli/tips.ts`):
660
+
661
+ 1. **stderr, always.** stdout is the command's result and must stay parseable — `quickchr inspect --json | jq` cannot be made to filter advice out of its own input.
662
+ 2. **A tip names a next command or a better-suited tool.** It never restates what just happened, and never appears where the command already said the thing.
663
+ 3. **Suppressible via `QUICKCHR_NO_TIPS=1`, and not gated on TTY.** The audience for "you probably wanted X" is agents and first-time users, and both usually run without one.
664
+
665
+ ### A mistyped or failed command leaves no state
666
+
667
+ Three CLI defects reported from one external agent's 3-CHR lab were the same defect: quickchr turned a mistake into durable state and then could not describe it.
668
+
669
+ - `--help` reached subcommands as an ordinary argument, so `add --help` created a machine and `networks sockets create --help` persisted a socket named `--help`. Help is now answered in the dispatcher, before dispatch, so a new subcommand cannot miss the guard.
670
+ - An unrecognised flag on `add`/`start` was inert, so a typo downloaded 43 MB and created a machine. Those two commands — the ones that create machines — now check their flags against a registry (`src/cli/flags.ts`) first. The read-only commands stay tolerant.
671
+ - A failure between `ensureDir()` and `saveMachine()` stranded a directory that `list` could not show, `remove` would not delete, and `add` still refused to reuse. `add` now cleans up after itself, and — because a SIGKILL still can strand one — `remove` clears a directory with no readable `machine.json`, `MACHINE_EXISTS` distinguishes the two cases, and `doctor` names `quickchr remove`, not `rm -rf`.
672
+
673
+ The rule the three share: **clearing quickchr's own mess must never require knowing that the data dir exists.** A recovery step whose only form is `rm -rf ~/.local/share/quickchr/machines/<name>` is not a recovery step; it is a leak of an internal layout into the user's hands. The same rule is why machine and socket names are validated before anything is written — both become a path segment, so "what names are legal" is a storage-layout question the user should never have to answer.
674
+
675
+ ### A flag's arity is declared, not guessed
676
+
677
+ `parseFlags` had one rule for two jobs: *if the next argument does not start with `--`, consume it as this flag's value.* That is right for `--version 7.24.3` and wrong for every boolean flag, which then eats the following positional — `quickchr start --vmnet-shared lab` set `vmnet-shared="lab"` and lost the machine name (#164). Arity now comes from `VALUE_FLAGS` in `src/cli/flags.ts`; a flag absent from that table is boolean and leaves the next argument alone. A typo is boolean too, which is why a misspelled `--version` followed by `lab` can now be reported as an unknown flag *with* the machine name intact, instead of silently consuming it.
678
+
679
+ The audit matters as much as the table. `--vmnet-shared` was found by accident while fixing something else, and the only way to find its siblings was to re-read every `flag()` call site by eye. `test/unit/cli-flag-arity.test.ts` scans the CLI source for every flag read and cross-checks each name against the registry in both directions — so the next flag cannot skip the table by being forgotten, and nobody has to do that reading again. Writing it paid for itself immediately: the first version scanned only the `flag()`/`flagBool()` helpers, pronounced the registry complete, and missed three `cache prune` flags read straight off the object (`flags["older-than"]`), which the arity change had already broken. An audit is only as wide as the ways the code actually reads a flag.
680
+
681
+ `--vmnet-shared` / `--vmnet-bridge <iface>` were removed rather than repaired: they were special cases for one network type that `--add-network` already expresses in general, both were already documented as deprecated aliases, and nothing generated them. A removed name is still recognized and errors with its replacement — deleting it outright would leave the flag "unknown", and for the tolerant read-only commands, ignored again. `vmnet-shared` is simultaneously the removed flag, QEMU's own netdev name, and a legacy `machine.json` value; only the first went, and a test pins the other two because the collision makes over-deletion easy.
682
+
683
+ ### An enumeration and a lookup fail differently
684
+
685
+ `loadMachine()` parsed `machine.json` with no guard, so one corrupt file — a `saveMachine()` that died mid-write — threw a raw `SyntaxError` out of `quickchr list` and destroyed the state of every healthy machine alongside it (#165). One policy was being applied to two contracts:
686
+
687
+ - **`get(name)` is a targeted lookup.** You named *that* machine, it is corrupt, and answering `null` would report a directory that exists as one that never did. It still throws — now `STATE_ERROR` naming the file and `quickchr remove <name>`, rather than a parse trace.
688
+ - **`list()` is an enumeration** — best-effort across many. One bad entry aborting it is the enumeration failing at its own job, and it costs more signal than it keeps.
689
+
690
+ Skipping alone would have traded a loud failure for a quiet one: a machine that was there yesterday just disappears, which is the shape of trouble that does *not* look like trouble. So the unreadable entry keeps its place in `list` as a row — marked, named, with the remedy attached — because it still holds a disk image and `list` is where anyone looks first. Deferring it to `doctor` assumes you already suspect something. Exit stays 0: the listing succeeded, and a non-zero exit would punish the working machines and break every script piping `list`.
691
+
692
+ "Unreadable" is not the same as "unparseable", and review caught the first version confusing them: it guarded `JSON.parse` and rejected primitives and arrays, so `{"name":"lab3"}` was still cast to `MachineState`, still enumerated, and still reached `formatPorts(m.ports)` — `Object.entries(undefined)`, the same wholesale abort with a different stack. `loadMachine()` now checks the fields callers dereference unguarded (`REQUIRED_STATE_FIELDS`). That list is deliberately not a schema: it is the set that turns a bad file into a crash somewhere else, and every field in it has been required on `MachineState` since the commit that introduced the type, so it cannot condemn state any version of quickchr wrote. `cpu`/`mem` are left out precisely because a missing one prints oddly rather than throwing.
693
+
694
+ Internally this is a three-way `tryLoadMachine()` (`ok` / `missing` / `unreadable`) that `loadMachine()` still collapses into "undefined or throw". Keeping the discriminated result unexported is the small diff; promoting it to public API is a rename if #58 lands.
695
+
696
+ ### A named socket says what it is
697
+
698
+ An external agent's 3-CHR lab brought up two CHRs on `--add-network socket::skylab-mir`,
699
+ got interfaces up, addresses assigned, and 100% packet loss with no error anywhere. It
700
+ found the cause by opening `machine.json`: the link was UDP multicast, and the sandbox
701
+ denied the send.
702
+
703
+ The lab re-ran the question against its own VM on 2026-09-20, which turned the original
704
+ hand-wave ("the sandbox blocks UDP") into something exact — and narrower:
705
+
706
+ - The host is **Linux** (Ubuntu 24.04, seccomp-bpf active, `NoNewPrivs=1`, four filters).
707
+ `iptables` and `nft` are empty, so no firewall is involved. **This lab was never on
708
+ macOS**, which is why its finding is independent of the macOS defect below.
709
+ - The denied call is **an unconnected `sendto()`/`sendmsg()` on an `AF_INET` `SOCK_DGRAM`
710
+ socket**, returning `EPERM` — strace-confirmed. Not "UDP". A `connect()`-ed UDP socket's
711
+ `send()` passes the filter, as does TCP, as does `AF_UNIX` `SOCK_DGRAM`.
712
+ - `socket()`, `bind()` and `IP_ADD_MEMBERSHIP` all succeed; only the send is refused. So
713
+ the group joins cleanly and then silently carries nothing.
714
+ - Loopback multicast fails **independently** of internet egress: `sendto()` to
715
+ `230.0.0.1:4000` gets the same `EPERM` as `sendto()` to `8.8.8.8:53`. It is not an
716
+ egress-only policy, which was the intuition that had to be tested rather than assumed.
717
+
718
+ That maps onto one line of QEMU, which is why `mcast` died there and `dgram` did not
719
+ (`net/socket.c`, `net_socket_receive_dgram()`):
720
+
721
+ ```c
722
+ ret = RETRY_ON_EINTR(
723
+ s->dgram_dst.sin_family != AF_UNIX ?
724
+ sendto(s->fd, buf, size, 0, (struct sockaddr *)&s->dgram_dst, sizeof(s->dgram_dst)) :
725
+ send(s->fd, buf, size, 0)
726
+ );
727
+ ```
728
+
729
+ `mcast` takes the `AF_INET` branch — the exact call the filter denies. `dgram` over unix
730
+ takes `send()`. `EPERM` is neither `EINTR` nor `EAGAIN`, so the frame is dropped and
731
+ nothing is logged: the 100% loss, mechanically.
732
+
733
+ One thing the lab explicitly withdrew: the failed 7.24.4 image downloads in the same
734
+ session were **TCP egress-proxy stalls**, not UDP. Do not cite them as evidence here.
735
+
736
+ Three separate defects converged on that one silence.
737
+
738
+ 1. **The only transport the CLI could reach was the one that fails silently.**
739
+ `createNamedSocket()` defaulted to `mcast`, and `networks sockets create` had no flag
740
+ to pick anything else.
741
+ 2. **`listen-connect`, the alternative the registry already modelled, had never worked.**
742
+ The role came from `entry.members.length === 0` at resolve time, but
743
+ `registerSocketMembers()` runs *before* `resolveAllNetworks()`, so the starting
744
+ machine had always added itself by then. `isFirst` was never true; every member
745
+ resolved to `connect=`; nobody ever listened.
746
+ 3. **Nothing printed the transport.** `start` did not name it, and `format.ts` tested
747
+ `s.type === "socket-named"` while the parser emits `{ type: "socket" }`, so `list`
748
+ and `info` fell through to `JSON.stringify` for every named socket.
749
+
750
+ #### What the transports actually do
751
+
752
+ Measured on QEMU 11.1.1, macOS Intel, CHR 7.24.4, rather than reasoned about — except
753
+ the `listen-connect` column, measured later on `windows-x86`/TCG, CHR 7.24.4 (see below):
754
+
755
+ | | `mcast` | `listen-connect` | `dgram` over unix |
756
+ |---|---|---|---|
757
+ | start order | any | listener first | **either end first** |
758
+ | frames verified | broken on macOS | ICMP both ways | ICMP both ways, plus MNDP and IPv6 ND |
759
+ | host ports | a multicast group | a TCP port | none |
760
+ | send call | unconnected `sendto()`, `AF_INET` | TCP `send()` | `send()`, `AF_UNIX` |
761
+ | machines | any number | 2 | 2 |
762
+ | where a failure lives | host kernel / sandbox policy | quickchr's own registry | quickchr's own registry |
763
+
764
+ `listen-connect` read `(was unreachable)` in that row until #172. It is the Windows
765
+ default, so it is the one transport whose first end-to-end proof had to come from CI:
766
+ run [35545697945](https://github.com/tikoci/quickchr/actions/runs/35545697945) created
767
+ the link with no `--mode` at all, started slot 0 and slot 1 in that order, and pinged
768
+ both ways over it. The QEMU side of the pair is not exotic — `network-mac.test.ts` had
769
+ long covered the raw `socket-listen`/`socket-connect` specifiers on Windows — but the
770
+ named-socket path around it (persisted mode, slot roles, auto-allocated port) had never
771
+ been exercised on a real boot anywhere.
772
+
773
+ The decisive point is not "TCP works on macOS" — it is **which silence quickchr can
774
+ see.** A TCP pair does not fail loudly either: `connect=` to a dead port runs on with
775
+ no error, no log and no exit, never retries when the listener appears later, and
776
+ `listen=` accepts exactly one peer while leaving the listening socket open, so a third
777
+ member's connect succeeds at the kernel level and receives nothing. But those failures
778
+ are member count and endpoint occupancy, which are already persisted. A multicast
779
+ failure lives in the host kernel, and no amount of bookkeeping can see it.
780
+
781
+ #### The decision
782
+
783
+ **A named socket defaults to `dgram` on macOS and Linux, and `listen-connect` on
784
+ Windows** (AF_UNIX there has no `SOCK_DGRAM`). `mcast` stays first-class as the only
785
+ N-way segment, and says at create time that it fails silently.
786
+
787
+ The resolved default is **persisted at creation, never re-derived**. A
788
+ `networks/<name>.json` states the transport it was made with, so one specifier never
789
+ silently means two things — the objection that ruled out a platform-sniffing default
790
+ in the first place.
791
+
792
+ Two dgram failure modes the design has to own, both found by probing rather than
793
+ reading:
794
+
795
+ - **A duplicate `local.path` silently steals the link.** A second QEMU unlinks the
796
+ first's socket and binds its own: the inode changes, both processes stay alive, and
797
+ neither logs anything. This is why a third machine is a refusal rather than a
798
+ warning, and why endpoints are claimed before QEMU is spawned.
799
+ - **`sun_path` is 104 bytes.** Checked at create time so the error names the data dir,
800
+ instead of arriving later as `UNIX socket path '...' is too long` against a path the
801
+ caller never chose.
802
+
803
+ Claiming an endpoint is a cross-process read-modify-write, so it takes a **registry
804
+ lock** and re-reads from disk inside it. Without one, `quickchr start a & quickchr start
805
+ b &` — the shape the field report actually used — has both machines read `[null, null]`,
806
+ both take slot 0, and the second write win: both QEMUs get the same `local.path`, and
807
+ the duplicate-path theft above does the rest. A two-process repro lost an endpoint on
808
+ 12 of 12 rounds. Entries are also written with write-then-rename, because a truncating
809
+ write let a concurrent reader see invalid JSON and report the socket as missing.
810
+
811
+ The lock itself had the same class of bug on its first cut: a waiter that found the
812
+ lock file created but not yet written to judged it stale and deleted it, so two
813
+ processes believed they held it. A lock file whose pid is unreadable is now given a
814
+ grace period before it counts as abandoned, which still reclaims a crashed holder.
815
+
816
+ Endpoints are held in **numbered slots** rather than derived from live membership.
817
+ Slot 0 is the listener (`listen-connect`) or endpoint A (`dgram`); the first machine
818
+ to start has to name its peer's path before that peer exists, and a slot is the only
819
+ thing both ends can agree on in advance. It also fixes (2) properly: a listener that
820
+ stops and restarts comes back as the listener instead of demoting itself to a second
821
+ connector.
822
+
823
+ A consequence worth stating, because it is easy to document wrongly: the roles are
824
+ fixed when the two machines *first* join, so `listen-connect` still has a start order
825
+ afterwards — the listening machine must be up before the connecting one, since
826
+ `connect=` fails silently and never retries. `dgram` has no ordering constraint at any
827
+ point. A claim is also released if the launch that made it never reaches persisted
828
+ state, so a start that fails after claiming an endpoint does not leave a link occupied
829
+ by a machine that is not running. The invariant is the plain one — **a machine that is
830
+ not running holds no endpoint** — and every path that reaches "stopped" has to honour
831
+ it, including the two that do not go through `stop()`: a foreground run whose QEMU has
832
+ exited, and `clean()`.
833
+
834
+ The registry needs a second, wider lock as well. Automatic port allocation is
835
+ `max(existing ports) + 1`, which reads *every* entry, so the per-entry lock does not
836
+ help when the contenders are different names — three `mcast` links created at once all
837
+ took port 4000 (15 of 15 rounds), silently collapsing three segments into one group.
838
+ The registry-wide lock is always taken *inside* the per-entry one, so the order is
839
+ fixed and two holders cannot deadlock.
840
+
841
+ The lock alone is not enough, though: allocation also has to *see* the current
842
+ registry. `listNamedSockets()` seeds itself from the in-memory cache and only reads
843
+ disk for names it does not already hold, so a cached entry whose on-disk port has
844
+ changed is never re-read — and the next allocation hands out a port that is taken.
845
+ Reading only from disk is not the fix either, because that cache exists to work around
846
+ Bun returning stale data from a read that follows a write on Windows. The two sources
847
+ go stale in opposite directions, so allocation takes the **union** of both: it can only
848
+ over-estimate the maximum, which wastes a port number, where trusting either one alone
849
+ under-estimates it and collides.
850
+
851
+ The acceptance bar, and the reason the visibility work is not cosmetic: **nothing about
852
+ a named socket should require opening a file under the data dir.** Every place the
853
+ field report had to read `machine.json` is a place quickchr knew the answer and did not
854
+ print it. The same reasoning applies to the CLI's own options — a `--group` that cannot
855
+ apply is an error rather than a silently dropped argument.
856
+
857
+ #### Still open
858
+
859
+ `dgram` and `listen-connect` both cap at two machines, and `mcast` — the N-way answer —
860
+ is broken on macOS. **quickchr still has no 3-node rootless L2 segment on macOS.** That
861
+ was true before this change; it is now explicit rather than presenting as a RouterOS
862
+ fault. Tracked in #167.
863
+
864
+ The in-repo alternative is a frame-repeating hub, and since `start` returns and `--bg` is
865
+ not a detach (#159), there is no process to host one — a hub is a daemon, and "no daemon"
866
+ is a standing design decision. So the work goes into making `mcast` usable instead, via
867
+ two independent defects.
868
+
869
+ **Ours:** quickchr never passes `localaddr=`, so `net_socket_mcast_create()` falls back to
870
+ `imr_interface = INADDR_ANY` and sets no `IP_MULTICAST_IF`. The group therefore rides the
871
+ host's default multicast interface rather than loopback. `IP_MULTICAST_LOOP` governs local
872
+ delivery, not scoping. A `dgram` link cannot leave the filesystem; an `mcast` one does.
873
+ The consequence — two people on one LAN using the default group joining each other's
874
+ segments — is **a reading of the code plus the lab's `netstat -gn`, not a demonstrated
875
+ two-host repro**; #167 step 1 is the probe that settles it.
876
+
877
+ **Upstream:** `net_socket_mcast_create()` sets only `SO_REUSEADDR`, on a stated assumption
878
+ that this suffices to share a multicast port — true on Linux, false on BSD/macOS, which
879
+ require `SO_REUSEPORT` on every such socket. Still that way on QEMU master
880
+ `c1c18d1e640b64292859ce9f30f3c344edfb0294` (2026-09-19). `test/lab/mndp/REPORT.md`
881
+ isolates it to QEMU's side: the host joined on every interface *with* `SO_REUSEPORT` and
882
+ still received nothing, and two CHRs on one group never discovered each other.
883
+
884
+ **The two are independent, and it matters.** The macOS defect is a *delivery* failure —
885
+ the send succeeds, only one socket receives. The Linux sandbox defect is a *permission*
886
+ failure — the send never happens. They were found in different environments by different
887
+ labs, neither reproduces the other, and fixing either leaves the other intact. Reading
888
+ "mcast is broken" as one story is how the sandbox finding ended up attached to a macOS
889
+ issue in the first place.
890
+
891
+ **`mcast` stays opt-in either way.** Fixing both removes specific causes of silent failure,
892
+ but neither overturns the asymmetry that chose the `dgram` default — and the sandbox lab is
893
+ itself the cleanest evidence for that default, since `dgram` carried frames in the exact
894
+ environment where `mcast` could not send at all.
895
+
896
+ The precise form of the asymmetry, corrected: it is **not** that mcast failures are
897
+ undetectable. An active probe at create or start time — one unconnected `sendto()` to a
898
+ loopback group — catches the whole permission class cheaply, including the one that
899
+ actually bit (#169). What a probe cannot catch is the *delivery* class: `SO_REUSEPORT`,
900
+ a downstream firewall, a VPN capturing the group, or another host already on it. The
901
+ durable difference is that a pair transport's misconfiguration is endpoint occupancy —
902
+ persisted, visible, and refused before spawn — whereas every mcast failure mode needs
903
+ an active measurement, and some are not reachable by one at all.
904
+
905
+ Nor is `mcast` the simpler mechanism it first appears: it removes slots, roles and start
906
+ order, but trades them for group and port allocation, which is where the
907
+ three-links-one-port collapse above came from — and a group collision merges segments
908
+ silently where a slot collision refuses.
909
+
910
+ One correction on the record, since the earlier draft of this section got it backwards.
911
+ The external lab's sandbox report was marked *unverified*, with the guess that it probably
912
+ only restricted internet-bound UDP. Both halves were wrong: the block is real, it is at the
913
+ syscall, and loopback multicast fails independently of egress. The lesson is the one this
914
+ section already argues elsewhere — the answer came from running the commands on the actual
915
+ host, not from reasoning about what a sandbox is likely to permit.
916
+
917
+ ### Background is not detachment, and `--bg` never claimed to be
918
+
919
+ `spawnQemu()` backgrounds QEMU and calls `unref()`. That is enough for the parent to
920
+ *exit* — QEMU is adopted by init/launchd and runs on — and it was read as detachment for
921
+ as long as nobody sent a signal. It leaves QEMU in the caller's process group, so anything
922
+ addressed to the group takes the VM with it: Ctrl-C in the terminal, a shell `timeout`, a
923
+ CI step teardown, an agent harness killing a command it thinks is stuck. The external lab
924
+ that hit this worked around it by wrapping every start in `nohup` (#159).
925
+
926
+ Windows had the right spawn for the wrong-looking reason. A Job Object terminates
927
+ everything inside it when the parent goes, and `Bun.spawn().unref()` does not escape one,
928
+ so that branch already used `node:child_process` with `detached: true` — and carried a
929
+ comment describing precisely the hazard the POSIX branch was exposed to. The two platforms
930
+ fail differently (a Job Object on parent *exit*; a process group on a *signal*), which is
931
+ why the asymmetry survived review: the Windows comment reads as Windows trivia rather than
932
+ as the general rule. It is the general rule, and `detached: true` is now set on both.
933
+
934
+ The *spawner* still differs, and deliberately. POSIX uses `Bun.spawn`, per the repo's
935
+ Bun-first rule, with its `detached` verified here on Bun 1.4.2 rather than taken from the
936
+ documentation — `test/unit/posix-detach.test.ts` is that verification. Windows keeps
937
+ `node:child_process`, because its Job Object escape is load-bearing and can only be
938
+ demonstrated on Windows. Bun documents `detached` as `UV_PROCESS_DETACHED` there, which
939
+ would probably collapse the two branches into one; that is a change to make from a Windows
940
+ reproduction, not from a doc claim. The first draft of this fix did unify them on
941
+ `node:child_process`, which review correctly called out as reaching for Node where Bun has
942
+ the API — the unification was the easy half, and the wrong half to keep.
943
+
944
+ Detaching does not weaken cleanup, because nothing that sweeps QEMU sweeps by group —
945
+ `scripts/ci-file-watchdog.ts` and the integration workflow both kill by process name. That
946
+ is worth stating rather than assuming: "the child outlives its parent's signal" and "a
947
+ wedged run leaks VMs" would otherwise look like the same change.
948
+
949
+ The naming half is not fixed and should not be fixed quietly. `--bg` is already the
950
+ default and only ever meant "serial console goes to a log file instead of stdio" — it does
951
+ not make `start` return early, because `start` waits for REST readiness by contract. Both
952
+ an external agent and the maintainer believed it bought them something. The honest repair
953
+ is a separate `--no-wait` that opts out of the wait, not a redefinition of `--bg` that
954
+ would change what existing scripts get; #159 holds that half.
955
+
355
956
  ### Out of Scope (decided)
356
957
 
357
958
  Explicitly rejected, with rationale, so they aren't re-proposed: