@tikoci/quickchr 0.4.7 → 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 +366 -5
- package/MANUAL.md +199 -14
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +10 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +17 -3
- package/src/lib/images.ts +53 -22
- package/src/lib/names.ts +72 -0
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +39 -12
- package/src/lib/quickchr.ts +248 -40
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +29 -5
package/DESIGN.md
CHANGED
|
@@ -177,6 +177,14 @@ See MANUAL.md's CLI reference and environment-variables sections for the full su
|
|
|
177
177
|
Full artifact map, dispatch recipes, and failure-diagnosis guide live in
|
|
178
178
|
`.github/instructions/ci.instructions.md` — this section is the high-level rationale only.
|
|
179
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
|
+
|
|
180
188
|
**The 2026-07 refactor (#29)** replaced the organically-grown scheme (integration on every
|
|
181
189
|
PR push, three parallel copies of the runner logic, `continue-on-error` green-washing)
|
|
182
190
|
with a layered design:
|
|
@@ -208,7 +216,9 @@ with a layered design:
|
|
|
208
216
|
- **`release.yml`** — one-click publish from committed release state: freshness gate
|
|
209
217
|
(no suite re-run — main is kept continuously release-able) + `package.json` version
|
|
210
218
|
with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
|
|
211
|
-
--provenance` (odd minor → `next`, even → `latest
|
|
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
|
|
212
222
|
to protected `main`.
|
|
213
223
|
- **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
|
|
214
224
|
record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
|
|
@@ -347,9 +357,17 @@ boot went silent:
|
|
|
347
357
|
paths (#106).
|
|
348
358
|
|
|
349
359
|
Corollary for CI cache keys: `actions/cache` only saves when the primary key
|
|
350
|
-
*missed*, so
|
|
351
|
-
|
|
352
|
-
|
|
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).
|
|
353
371
|
|
|
354
372
|
7. **The boot probe can break the service it is waiting for.** `restGet()`
|
|
355
373
|
implements its deadline as `req.destroy()` — a mid-flight TCP teardown. On
|
|
@@ -455,6 +473,48 @@ The `default-route-distance=2` ensures the shared route is backup — SLiRP ethe
|
|
|
455
473
|
|
|
456
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/`.
|
|
457
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
|
+
|
|
458
518
|
#### Host-Side L2 Capture (MNDP, MAC-Telnet)
|
|
459
519
|
|
|
460
520
|
A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
|
|
@@ -477,7 +537,7 @@ capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
|
|
|
477
537
|
The dual of `hostfwd`: SLIRP's gateway `10.0.2.2` *is* the host from inside the
|
|
478
538
|
guest, so guest-originated UDP to `10.0.2.2:<port>` reaches a host socket bound on
|
|
479
539
|
loopback `<port>` with **no forward and no extra NIC**. This generalizes the TZSP
|
|
480
|
-
path (`
|
|
540
|
+
path (`hostGatewayIp`/`captureInterface`) — it is not TZSP-specific and reaches an
|
|
481
541
|
ordinary bound socket, not just a `tshark`/pcap capture. The host socket **must be
|
|
482
542
|
unconnected**: SLIRP re-emits from a rewritten loopback source
|
|
483
543
|
(`127.0.0.1:<ephemeral>`), which a `connect()`-ed socket would filter. This closed
|
|
@@ -592,6 +652,307 @@ the CLI/library surface is tracked in
|
|
|
592
652
|
|
|
593
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)).
|
|
594
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
|
+
|
|
595
956
|
### Out of Scope (decided)
|
|
596
957
|
|
|
597
958
|
Explicitly rejected, with rationale, so they aren't re-proposed:
|