@tikoci/quickchr 0.4.5 → 0.4.7
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 +260 -7
- package/MANUAL.md +44 -12
- package/package.json +1 -1
- package/src/cli/index.ts +31 -0
- package/src/index.ts +1 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +3 -39
- package/src/lib/net.ts +59 -3
- package/src/lib/packages.ts +6 -12
- package/src/lib/platform.ts +153 -19
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +266 -26
- package/src/lib/settings-file.ts +52 -0
- package/src/lib/settings.ts +20 -42
- package/src/lib/types.ts +37 -1
- package/src/lib/versions.ts +99 -22
package/DESIGN.md
CHANGED
|
@@ -54,6 +54,49 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
54
54
|
|
|
55
55
|
**Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.
|
|
56
56
|
|
|
57
|
+
10. **arm64 CHR is forced onto TCG on all Apple Silicon** — `detectAccel("arm64")` never returns `hvf` on macOS; the launch path surfaces `accelNote()` explaining why (`src/lib/platform.ts`). The cause is an **image artifact**, not an accelerator or CPU-model bug: current arm64 CHR images (7.20.8 – 7.23beta5 verified) ship an AArch64 kernel over a **32-bit ARM userspace** — the appended initramfs `/init` is `ELF 32-bit LSB ARM, EABI5, static`, and the 7.22.1 `system` package holds **101 further ARM32 executables and 18 ARM32 shared objects** (the only AArch64 executables are `kexec` and `vmcore-dmesg`). Apple Silicon implements **no AArch32 at any exception level**: `ID_AA64PFR0_EL1` reports AArch64-only for EL0/EL1, and QEMU under HVF passes that hardware register straight through (`hvf_arch_init_vcpu()` re-reads the live vCPU register and only edits the GIC bit; the `-cpu` model is inert). So the guest kernel never sets `ARM64_HAS_32BIT_EL0`, `compat_elf_check_arch()` rejects the `EM_ARM` `/init` with `-ENOEXEC`, the initramfs has no fallback init, and Linux panics at t≈0.076 s with `No working init found`. TCG's emulated models do provide AArch32 EL0, so the same unmodified image boots to `MikroTik Login:`. Grounded in the guest's own panic-time capability bitmap: the M4/HVF guest printed `0x20012,28000230` (8 caps, `ARM64_HAS_32BIT_EL0` absent) against TCG's `0x20013,28402230` (11 caps, present). Full chain in [`docs/m4-hvf-arm64-investigation.md`](docs/m4-hvf-arm64-investigation.md).
|
|
58
|
+
|
|
59
|
+
**Scope is every Apple Silicon generation, not M4+.** The first report came from an M4 (tikoci/mikropkl#11) and the original fix keyed on `hw.optional.arm.FEAT_SSBS == 0`, which M4 is the first Apple chip to report — but SSBS was a **coincident marker of the reporting host, not the mechanism**. Linux 5.6 treats SSBS as an optional mitigation and boots fine without it (`cortex-a53`/`neoverse-n1` lack it), while *no* Apple CPU since 2020 implements AArch32. The SSBS predicate therefore left M1/M2/M3 on HVF and panicking — a live bug, not merely a narrow gate. Ordinary server ARM (Ampere, Graviton) does implement AArch32 EL0, which is why arm64 CHR runs there under KVM; this is an Apple-Silicon-and-CHR-artifact interaction, not a RouterOS-on-ARM defect.
|
|
60
|
+
|
|
61
|
+
**No QEMU flag or version fixes this.** Apple's `Hypervisor.framework` exposes only `hv_vcpu_config_get_feature_reg()` — there is no setter — so no macOS VMM can present a feature the silicon lacks (UTM's fork behaves identically). A QEMU version floor is therefore *not* a valid restore signal, and the deferred `getQemuVersion()` guard the SSBS-era decision contemplated has been dropped. **The restore signal is the guest artifact:** a future arm64 CHR whose appended `/init` *and* required system-package executables/shared objects are all AArch64, confirmed by a real HVF boot. That check is mechanical (§"Reproduction" in the investigation doc) and could become a release-time guard.
|
|
62
|
+
|
|
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
|
+
|
|
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
|
+
|
|
57
100
|
## Port Layout
|
|
58
101
|
|
|
59
102
|
| Offset | Service | Guest Port |
|
|
@@ -94,14 +137,18 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
|
|
|
94
137
|
│ ├── serial.sock # Serial console
|
|
95
138
|
│ ├── qga.sock # QGA (x86 only)
|
|
96
139
|
│ ├── qemu.pid # PID file
|
|
97
|
-
│
|
|
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
|
|
98
144
|
```
|
|
99
145
|
|
|
100
146
|
**Global settings** live separately, under the XDG **config** tier (not the data tree
|
|
101
147
|
above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
|
|
102
148
|
Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
|
|
103
|
-
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The
|
|
104
|
-
`default-channel`, `default-arch`, `cache-max-size`, `timeout-extra`,
|
|
149
|
+
`QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 6 managed keys:
|
|
150
|
+
`default-channel`, `default-arch`, `accel`, `cache-max-size`, `timeout-extra`,
|
|
151
|
+
`secure-login`.
|
|
105
152
|
See MANUAL.md's CLI reference and environment-variables sections for the full surface.
|
|
106
153
|
|
|
107
154
|
## Platform Support
|
|
@@ -109,16 +156,20 @@ See MANUAL.md's CLI reference and environment-variables sections for the full su
|
|
|
109
156
|
| Platform | x86 CHR | arm64 CHR | Notes |
|
|
110
157
|
|------------------------|---------|-----------|-------|
|
|
111
158
|
| macOS x86_64 | HVF | TCG | Intel Mac |
|
|
112
|
-
| macOS arm64 (native) |
|
|
113
|
-
| macOS arm64 (Rosetta) |
|
|
159
|
+
| macOS arm64 (native) | TCG | TCG¹ | Apple Silicon, bun is arm64 |
|
|
160
|
+
| macOS arm64 (Rosetta) | TCG | TCG¹ | Apple Silicon, bun reports x86_64 |
|
|
114
161
|
| Linux x86_64 | KVM | TCG | KVM requires `/dev/kvm` writable |
|
|
115
162
|
| Linux aarch64 | TCG | KVM | x86 TCG on arm64 Linux |
|
|
116
163
|
| Windows x86_64 | TCG | TCG | HVF/KVM not available |
|
|
117
164
|
|
|
165
|
+
¹ arm64 CHR is forced to **TCG on every Apple Silicon generation**: the image's required userspace is 32-bit ARM and Apple CPUs implement no AArch32, so an HVF guest panics at init (see Design Decisions #10). x86 CHR also uses TCG because HVF cannot virtualize x86 on an arm64 host. Override with `--accel hvf` to test a future AArch64-only image.
|
|
166
|
+
|
|
118
167
|
**Acceleration detection** (`detectAccel`):
|
|
119
|
-
-
|
|
168
|
+
- An explicit override (`--accel` flag > `QUICKCHR_ACCEL` env > `accel` in `quickchr.env`) short-circuits detection entirely; `auto` (the default) runs the checks below.
|
|
169
|
+
- macOS: checks `kern.hv_support` via sysctl. Intel x86 guests get HVF; **all guests on Apple Silicon get TCG** — x86 because HVF cannot virtualize across architectures, arm64 because the CHR image needs AArch32 (Design Decisions #10). A Rosetta process is identified via `sysctl.proc_translated`.
|
|
120
170
|
- Linux: checks `/dev/kvm` writability.
|
|
121
|
-
- Falling back to TCG is
|
|
171
|
+
- Falling back to TCG is safe but can be significantly slower; measure with the
|
|
172
|
+
target host, guest architecture, and RouterOS version.
|
|
122
173
|
|
|
123
174
|
## CI System
|
|
124
175
|
|
|
@@ -176,6 +227,33 @@ incidentally; crediting those suppressed the scheduler for versions no full suit
|
|
|
176
227
|
targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
|
|
177
228
|
rollup after fold-logic changes.
|
|
178
229
|
|
|
230
|
+
**What survives losing a runner (#76, #77)**: measured on run
|
|
231
|
+
[30665449265](https://github.com/tikoci/quickchr/actions/runs/30665449265), whose three
|
|
232
|
+
`macos-x86` legs stopped communicating mid-suite. This is the constraint every
|
|
233
|
+
lost-runner instrument has to be designed around, so it is recorded rather than
|
|
234
|
+
re-derived:
|
|
235
|
+
|
|
236
|
+
| Evidence | Survives? |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| Uploaded artifacts | **No** — `if: always()` steps never run |
|
|
239
|
+
| 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 |
|
|
240
|
+
| 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` |
|
|
241
|
+
| A check run PATCHed during the leg | **Yes** — it is server-side the moment it is written, and stays un-closed |
|
|
242
|
+
|
|
243
|
+
So a progress marker has to be *pushed out of the runner as it goes*; nothing collected
|
|
244
|
+
at the end can work. That is what `ci-leg-checkpoint.ts` (per-file check-run updates) and
|
|
245
|
+
`ci-leg-ledger.ts` (planned-vs-completed reconciliation into `ci-data/attempted-legs.json`)
|
|
246
|
+
implement. The ledger is a **separate file from `tested-versions.json` by decision**: the
|
|
247
|
+
version scheduler reads that file as a presence test, so recording an aborted run there
|
|
248
|
+
would make the version look tested and silently stop rescheduling it.
|
|
249
|
+
|
|
250
|
+
The same run also pins down #76's shape: the three legs died **62.0 / 64.1 / 65.0 minutes
|
|
251
|
+
into their own jobs**, staggered with their own start times across 14 minutes of
|
|
252
|
+
wall-clock. So the boundary is per-job elapsed, not a shared external event and not a
|
|
253
|
+
round 60-minute constant. Read `completed_at` as an **upper bound** on the wedge — it is
|
|
254
|
+
when the service gave up on a silent runner, and the interval between the runner going
|
|
255
|
+
quiet and that verdict is uncharacterized.
|
|
256
|
+
|
|
179
257
|
**Merge policy**: squash-only, PR title → main commit subject (write PR titles as
|
|
180
258
|
conventional commits), PR body → commit body, branches auto-delete. Review threads must
|
|
181
259
|
be resolved before merge (`required_conversation_resolution`) and automated reviews must
|
|
@@ -191,6 +269,166 @@ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inpu
|
|
|
191
269
|
- `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
|
|
192
270
|
machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch
|
|
193
271
|
|
|
272
|
+
### Boot-Failure Forensics (`src/lib/diagnostics.ts`)
|
|
273
|
+
|
|
274
|
+
Discovered constraint from the `ci:slow-platform-flake` series (#76 #79 #80 #91):
|
|
275
|
+
**a failure that cleans up after itself is unfixable.** Both `BOOT_TIMEOUT` paths
|
|
276
|
+
used to `stop()` + `remove()` the machine and embed `qemu.log.slice(-1200)`, which
|
|
277
|
+
on every recorded CI failure contained nothing but the cleanup SIGTERM. Four rules
|
|
278
|
+
came out of that:
|
|
279
|
+
|
|
280
|
+
1. **`waitForBoot()` classifies, it does not just return `false`.** A boot that
|
|
281
|
+
never becomes REST-ready is several different bugs wearing the same error:
|
|
282
|
+
`refused` = QEMU never bound the port (host-side); `probe-timeout` = it bound
|
|
283
|
+
and nothing behind it answered (guest-side); `reset`/`wrong-body` = the guest
|
|
284
|
+
answered and RouterOS is still settling (the #69 shape). Collapsing all of
|
|
285
|
+
them to a boolean is why #79 stayed open across two full CI runs. The tally
|
|
286
|
+
rides on an optional `BootProbeStats` out-param, so normal boots pay nothing.
|
|
287
|
+
|
|
288
|
+
**Do not read a live host port as "the guest is up."** Under the default
|
|
289
|
+
`user` (slirp) network mode QEMU's hostfwd `listen()`s for the process's whole
|
|
290
|
+
lifetime and accepts before it tries to reach the guest. Verified locally
|
|
291
|
+
(2026-07-27) by starting CHR with `mem: 32`: 72/72 probes `probe-timeout`,
|
|
292
|
+
`hostfwd: accepting`, `info status: running`, empty `serial.log`. So
|
|
293
|
+
`refused` is much rarer than intuition suggests, and `probe-timeout` — not
|
|
294
|
+
`refused` — is the ordinary signature of a guest that never booted.
|
|
295
|
+
|
|
296
|
+
2. **The report is written outside the machine directory**, to
|
|
297
|
+
`<dataDir>/failures/`. Every integration test wraps its body in
|
|
298
|
+
`finally { cleanupMachine(name) }` → `remove()`, which deletes the machine
|
|
299
|
+
dir. A report written next to `qemu.log` is gone before CI can upload it,
|
|
300
|
+
*regardless* of `QUICKCHR_PRESERVE_ON_FAILURE`. The report therefore embeds
|
|
301
|
+
log contents rather than pointing at them, and stands alone.
|
|
302
|
+
|
|
303
|
+
3. **`QUICKCHR_SERIAL_LOG=1` is opt-in, and must stay opt-in.** It adds
|
|
304
|
+
`logfile=` to the serial chardev, which is the only way to see what RouterOS
|
|
305
|
+
printed on a boot that never reaches REST — the socket chardev keeps no
|
|
306
|
+
history, so connecting *after* the failure shows nothing. But serial-console
|
|
307
|
+
provisioning types the generated user password in cleartext, so the log is
|
|
308
|
+
secret-bearing. CI turns it on because CI passwords are per-run throwaways.
|
|
309
|
+
|
|
310
|
+
4. **Timeouts report where they stalled.** `monitorCommand()` records
|
|
311
|
+
connect → first byte → prompt → command written → response first byte, and
|
|
312
|
+
renders them into the timeout message. "Monitor command timed out" alone
|
|
313
|
+
cannot distinguish #80's hypotheses (monitor never greeted us vs. QEMU took
|
|
314
|
+
the command and went quiet); the phase line can, at the cost of a few
|
|
315
|
+
`Date.now()` calls on the success path.
|
|
316
|
+
|
|
317
|
+
Two more came out of #105/#106, once the reports above proved only *that* #79's
|
|
318
|
+
boot went silent:
|
|
319
|
+
|
|
320
|
+
5. **Evidence has to localize, so the capture is layered by invasiveness.**
|
|
321
|
+
Host-side per-port classification from `info usernet` (read-only, always on)
|
|
322
|
+
→ read-only guest snapshot over the serial console, which stays reachable
|
|
323
|
+
while REST is dead (on whenever a serial channel exists) → the counting-rule
|
|
324
|
+
probe, which writes a mangle rule into the guest and is therefore opt-in via
|
|
325
|
+
`QUICKCHR_DEEP_BOOT_DIAGNOSTICS=1` and skipped under
|
|
326
|
+
`QUICKCHR_PRESERVE_ON_FAILURE=1`. Each layer answers a question the one
|
|
327
|
+
before it cannot: `SYN_SENT` proves a silent drop but not *whose*; only a
|
|
328
|
+
counter inside the guest separates "RouterOS dropped it" from "it never
|
|
329
|
+
arrived". The RouterOS-side details (why mangle `passthrough` and not a
|
|
330
|
+
filter `accept`, why conntrack cannot answer this) live in
|
|
331
|
+
`provisioning.instructions.md`; the `info usernet` reading in
|
|
332
|
+
`qemu.instructions.md`.
|
|
333
|
+
|
|
334
|
+
The same secrecy rule as (3) applies one level up: guest payloads go to the
|
|
335
|
+
JSON report, and only credential-free shapes — row counts, booleans, a
|
|
336
|
+
verdict — reach the thrown error, which CI echoes into public job logs.
|
|
337
|
+
|
|
338
|
+
6. **A timeout that outlives the forensics is not optional.** Integration tests
|
|
339
|
+
hardcoding `300_000` against a 480 s same-arch TCG budget meant bun killed
|
|
340
|
+
the test before `waitForBoot()` gave up, so the capture above never ran —
|
|
341
|
+
four #79 reproductions arrived bare for exactly this reason. Tests derive
|
|
342
|
+
their timeout from `defaultBootTimeout()` plus `BOOT_FORENSICS_BUDGET_MS`
|
|
343
|
+
(`test/integration/timeouts.ts`), and a unit test asserts the invariant
|
|
344
|
+
across every arch/accel combination. Note this is alignment, not tuning: the
|
|
345
|
+
4× same-arch TCG factor is still ~11× the measured 44 s worst case, and
|
|
346
|
+
cutting it needs its own measurement of the package-install and device-mode
|
|
347
|
+
paths (#106).
|
|
348
|
+
|
|
349
|
+
Corollary for CI cache keys: `actions/cache` only saves when the primary key
|
|
350
|
+
*missed*, so a static key means a newly-resolved RouterOS version re-downloads
|
|
351
|
+
every run forever (#91). Primary keys must carry a rotating component, with
|
|
352
|
+
`restore-keys` doing the prefix fallback.
|
|
353
|
+
|
|
354
|
+
7. **The boot probe can break the service it is waiting for.** `restGet()`
|
|
355
|
+
implements its deadline as `req.destroy()` — a mid-flight TCP teardown. On
|
|
356
|
+
its own that is harmless. But an aborted request **followed by a completed
|
|
357
|
+
one** corrupts RouterOS `www`: the guest then resets incoming connections,
|
|
358
|
+
and a surviving connection is answered with the status computed for the
|
|
359
|
+
*aborted* request. An `admin:` probe carrying a valid empty password comes
|
|
360
|
+
back `401`. `waitForBoot()` emits exactly that pattern — overrun a probe,
|
|
361
|
+
then succeed on the next one 2 s later.
|
|
362
|
+
|
|
363
|
+
Reproduced deterministically on RouterOS 7.21.5, x86/HVF **and** arm64
|
|
364
|
+
cross-arch TCG, with `curl` in a separate process confirming it is
|
|
365
|
+
guest-side rather than a Bun `node:http` artifact
|
|
366
|
+
(`test/lab/www-abort-damage/`). Two consequences for design: a slow-but-alive
|
|
367
|
+
REST layer must be **waited for** rather than aborted, and backing off after
|
|
368
|
+
consecutive aborts matters because it is the abort/complete *alternation*,
|
|
369
|
+
not the abort count, that does the damage. It also means a REST status
|
|
370
|
+
received after any timeout in the same conversation cannot be trusted to
|
|
371
|
+
belong to the request that received it — the #69 surface.
|
|
372
|
+
|
|
373
|
+
Bounds, so this is not over-read: locally `www` recovers in 1.3–2.4 s, so the
|
|
374
|
+
*permanent* wedge in #79's CI forensics is **not** reproduced, and neither is
|
|
375
|
+
whatever made CI's very first probe exceed 3 s — locally a failed login
|
|
376
|
+
answers in ~125 ms even at `cpu-load: 100`. Both remain open, and the entry
|
|
377
|
+
point to the CI failure is still the unexplained one.
|
|
378
|
+
|
|
379
|
+
8. **Readiness is not the end of the evidence.** Items 1–7 all instrument a boot
|
|
380
|
+
that never became REST-ready. #69 is the other shape: the machine boots, it
|
|
381
|
+
passes `waitForBoot()`, and a *later* request is reset — so every instrument
|
|
382
|
+
above was skipped and the failure arrived as one line of `ECONNRESET`.
|
|
383
|
+
`captureRunningFailure()` (`quickchr.ts`) runs the same instrument set against
|
|
384
|
+
a machine that is still up, and does not stop, remove, or otherwise touch it —
|
|
385
|
+
the caller owns the lifecycle and may well keep testing against it.
|
|
386
|
+
|
|
387
|
+
What that capture has to add is a `trigger` (`FailureTrigger` in
|
|
388
|
+
`diagnostics.ts`): the failed operation, the error with its `code`/`errno`,
|
|
389
|
+
`sinceReadyMs`, and the credential transition that preceded it. Without it the
|
|
390
|
+
report is a healthy machine with no statement of what broke — a boot-failure
|
|
391
|
+
report explains itself through `restProbe`, and here `restProbe` is null. The
|
|
392
|
+
transition is **stated by the caller, never inferred**: the capture can see
|
|
393
|
+
the auth header, but "who this request authenticates as" and "what just
|
|
394
|
+
changed on the guest" are different facts, and only the caller knows the
|
|
395
|
+
second. An absent field reads as "not recorded", which is true; a guessed one
|
|
396
|
+
would send the next reader down a path nobody actually took.
|
|
397
|
+
|
|
398
|
+
The two report kinds share one directory and one 20-file cap, which is why
|
|
399
|
+
pruning orders by the timestamp *inside* the filename rather than by the
|
|
400
|
+
filename: whole-name sort was chronological only while every report carried
|
|
401
|
+
one prefix, and would now rank every `post-readiness-failure-` above every
|
|
402
|
+
`boot-failure-`, deleting the newest of one kind to keep the oldest of the
|
|
403
|
+
other.
|
|
404
|
+
|
|
405
|
+
Building that capture surfaced a defect in the instrument it reuses. The
|
|
406
|
+
guest snapshot's per-query budget was sized on the ~0.3 s cost of a command on
|
|
407
|
+
an already-open console session, which left the **~11.4 s serial login**
|
|
408
|
+
unbudgeted — and `bootFailureGuestExec()` then *divided* that budget across
|
|
409
|
+
credential candidates, so no attempt could finish logging in. No credential was
|
|
410
|
+
ever marked working, every later query repeated the same split, and the
|
|
411
|
+
snapshot spent its full 60 s to report `consoleReachable: false` about a guest
|
|
412
|
+
answering serial in 10 ms. It read as a broken console, which is why it went
|
|
413
|
+
unnoticed on the boot path: there the guest usually *is* unreachable, so the
|
|
414
|
+
wrong answer looked right. Queries now carry a login allowance until one of
|
|
415
|
+
them answers (`GUEST_LOGIN_ALLOWANCE_MS`), with the per-candidate floor raised
|
|
416
|
+
to the measured login cost; the 60 s snapshot budget and the 180 s forensics budget
|
|
417
|
+
are unchanged, so no test timeout moves. Locally this took a capture from
|
|
418
|
+
61.1 s / 0 of 7 queries to 14.4 s / 7 of 7.
|
|
419
|
+
|
|
420
|
+
The general rule, since it is not obvious from the numbers: a console budget
|
|
421
|
+
has two parts that differ by ~40×, and only one of them is a round-trip.
|
|
422
|
+
`provisioning.instructions.md` has the phase-by-phase measurements.
|
|
423
|
+
|
|
424
|
+
Test-side, the same finding forces a single REST client:
|
|
425
|
+
`test/integration/chr-rest.ts` is the only way integration tests reach CHR.
|
|
426
|
+
Tests previously used Bun `fetch()` and, in one file, a hand-rolled `node:http`
|
|
427
|
+
copy of `restGet` — two extra connection-reuse policies that made "client or
|
|
428
|
+
guest?" unanswerable on every reset. Collapsing them is **confound removal,
|
|
429
|
+
not a fix**: run 30507484030 reset through `restGet` too, and
|
|
430
|
+
`test/lab/bun-pool/` never reproduced the pooling bug it was built to catch.
|
|
431
|
+
|
|
194
432
|
## Design Principles
|
|
195
433
|
|
|
196
434
|
### Scope Boundary — QEMU Expert, Not Orchestrator
|
|
@@ -300,6 +538,21 @@ Provisioning via REST API is preferred (simple HTTP calls). Serial console provi
|
|
|
300
538
|
|
|
301
539
|
**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.
|
|
302
540
|
|
|
541
|
+
**`machine.json` holds two kinds of fact, and `clean()` is where they diverge.**
|
|
542
|
+
Some fields describe the *guest* — what accounts exist on the disk right now:
|
|
543
|
+
`user`, `managedSshKey`, `disableAdmin`, and the per-instance entry in the secret
|
|
544
|
+
store. Others are *intent* — what to apply the next time provisioning runs:
|
|
545
|
+
`packages`, `deviceMode`, `secureLogin`. `clean()` replaces the disk with a fresh
|
|
546
|
+
image, so every guest fact it recorded is false the moment it returns, and nothing
|
|
547
|
+
restores them: a post-`clean()` `start()` reaches `_launchExisting(…, undefined)`
|
|
548
|
+
because `lastStartedAt` is set, so the erased account is never recreated. `clean()`
|
|
549
|
+
therefore clears the guest facts (and deletes the managed keypair under
|
|
550
|
+
`<machineDir>/ssh/`) and keeps the intent. Without that, credential resolution
|
|
551
|
+
kept preferring `state.user` and authenticated every REST call, exec, and SCP as a
|
|
552
|
+
user RouterOS had deleted — the confound removed ahead of #79's QEMU-version
|
|
553
|
+
experiment. Priority (3) above is what a cleaned machine must land on, and it is
|
|
554
|
+
the truth of a fresh CHR image: `admin` with an empty password.
|
|
555
|
+
|
|
303
556
|
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`.
|
|
304
557
|
|
|
305
558
|
### Examples Philosophy
|
package/MANUAL.md
CHANGED
|
@@ -199,9 +199,17 @@ credentials from the secret store.
|
|
|
199
199
|
#### `clean <name>`
|
|
200
200
|
|
|
201
201
|
Resets the machine to a fresh image: stops QEMU, re-copies the boot
|
|
202
|
-
image from cache, recreates extra disks per the saved config,
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
image from cache, recreates extra disks per the saved config, and clears
|
|
203
|
+
every credential the old disk held — the per-instance secret-store entry,
|
|
204
|
+
the provisioned `user`, the managed SSH keypair, and the `disableAdmin`
|
|
205
|
+
flag. The next `start` therefore logs in as factory `admin` with an empty
|
|
206
|
+
password. EFI vars are deliberately **kept** (they store boot order, not
|
|
207
|
+
guest state; wiping them costs an arm64 device rescan).
|
|
208
|
+
|
|
209
|
+
`clean` does not re-provision: the boot options in `machine.json`
|
|
210
|
+
(`packages`, `deviceMode`, `secureLogin`) are re-applied only on a machine
|
|
211
|
+
that has never started, so a cleaned machine comes back factory-fresh. Pass
|
|
212
|
+
the provisioning flags again, or `remove` and re-create, to get them back.
|
|
205
213
|
|
|
206
214
|
### Inspection
|
|
207
215
|
|
|
@@ -398,12 +406,18 @@ Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
|
|
|
398
406
|
|---|---|---|---|
|
|
399
407
|
| `default-channel` | `add`/`start`'s `--channel`, wizard's channel prompt | `stable`\|`long-term`\|`testing`\|`development` | `stable` |
|
|
400
408
|
| `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
|
|
409
|
+
| `accel` | `add`/`start`'s `--accel`; every `detectAccel()` call in the run | `auto`\|`tcg`\|`hvf`\|`kvm` | `auto` (detect) |
|
|
401
410
|
| `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
|
|
402
411
|
| `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
|
|
403
412
|
| `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |
|
|
404
413
|
|
|
414
|
+
Anything other than `auto` for `accel` is handed to QEMU verbatim and **skips detection
|
|
415
|
+
entirely**, including the arm64-on-Apple-Silicon TCG fallback (§Platform notes) and the
|
|
416
|
+
availability checks — force `kvm` on macOS and QEMU itself reports the error. That is
|
|
417
|
+
deliberate: the override exists to test an accelerator the detector currently refuses.
|
|
418
|
+
|
|
405
419
|
`secure-login` is the one key `settings print`/`get` report as `(unset)` rather than a
|
|
406
|
-
concrete `false`, unlike the
|
|
420
|
+
concrete `false`, unlike the others: the setup wizard's login prompt needs to tell
|
|
407
421
|
"not configured" apart from "explicitly set to false" (it only pre-highlights "Keep
|
|
408
422
|
admin with no password" when the setting is concretely `false`; leaving it unset keeps
|
|
409
423
|
the wizard's own recommended "managed login" highlighted). Giving it a real
|
|
@@ -412,7 +426,7 @@ the wizard's own recommended "managed login" highlighted). Giving it a real
|
|
|
412
426
|
the setting resolves to exactly `true`.
|
|
413
427
|
|
|
414
428
|
```bash
|
|
415
|
-
quickchr settings print # all
|
|
429
|
+
quickchr settings print # all 6 keys, resolved value + source
|
|
416
430
|
quickchr settings set default-channel testing # write one key to quickchr.env
|
|
417
431
|
quickchr settings get default-arch # print one key's resolved value
|
|
418
432
|
quickchr settings reset default-channel # delete one managed line
|
|
@@ -420,7 +434,7 @@ quickchr settings reset # clear all managed lines
|
|
|
420
434
|
```
|
|
421
435
|
|
|
422
436
|
Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
|
|
423
|
-
never stored here — `settings` only manages the
|
|
437
|
+
never stored here — `settings` only manages the 6 keys above, and does not
|
|
424
438
|
mutate any machine's `machine.json`.
|
|
425
439
|
|
|
426
440
|
### Global behavior
|
|
@@ -431,6 +445,9 @@ mutate any machine's `machine.json`.
|
|
|
431
445
|
| `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
|
|
432
446
|
| `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
|
|
433
447
|
| `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
|
|
448
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` keeps the machine directory after a boot timeout instead of removing it (QEMU is still stopped) |
|
|
449
|
+
| `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
|
|
450
|
+
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `=1` lets a boot-timeout capture write to the guest (a mangle counting rule + host probes) to localize a silent packet drop. Skipped when `QUICKCHR_PRESERVE_ON_FAILURE=1` |
|
|
434
451
|
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
|
|
435
452
|
| `NO_COLOR` | Disable ANSI styling |
|
|
436
453
|
|
|
@@ -923,16 +940,20 @@ arch:
|
|
|
923
940
|
|---|---|---|
|
|
924
941
|
| macOS x86_64 | x86 | HVF |
|
|
925
942
|
| macOS x86_64 | arm64 | TCG |
|
|
926
|
-
| macOS arm64 (native bun) | arm64 |
|
|
943
|
+
| macOS arm64 (native bun) | arm64 | TCG |
|
|
927
944
|
| macOS arm64 (native bun) | x86 | TCG |
|
|
928
945
|
| macOS arm64 (Rosetta bun) | arm64 | TCG |
|
|
946
|
+
| macOS arm64 (Rosetta bun) | x86 | TCG |
|
|
929
947
|
| Linux x86_64 + `/dev/kvm` writable | x86 | KVM |
|
|
930
948
|
| Linux aarch64 + `/dev/kvm` writable | arm64 | KVM |
|
|
931
949
|
| any other combo | any | TCG |
|
|
932
950
|
|
|
933
951
|
`Bun` running under Rosetta on Apple Silicon reports `process.arch ===
|
|
934
|
-
"x64"`, so
|
|
935
|
-
|
|
952
|
+
"x64"`, so quickchr checks `sysctl.proc_translated` to identify the physical
|
|
953
|
+
arm64 host. Auto-selection uses TCG for both guest architectures there: HVF
|
|
954
|
+
cannot virtualize x86 across architectures, and current arm64 CHR images need
|
|
955
|
+
AArch32 userspace support that Apple Silicon does not provide. Use `--accel`
|
|
956
|
+
(or `QUICKCHR_ACCEL` / the `accel` setting) only to override this deliberately.
|
|
936
957
|
|
|
937
958
|
### Boot timeout
|
|
938
959
|
|
|
@@ -996,7 +1017,7 @@ managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
|
|
|
996
1017
|
preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
|
|
997
1018
|
`reset <key>` deletes the line entirely rather than blanking it, since a blank
|
|
998
1019
|
`KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
|
|
999
|
-
the
|
|
1020
|
+
the 6 managed keys are all structural preferences, not secrets.
|
|
1000
1021
|
|
|
1001
1022
|
### Environment variables consumed
|
|
1002
1023
|
|
|
@@ -1006,8 +1027,12 @@ the 5 managed keys are all structural preferences, not secrets.
|
|
|
1006
1027
|
| `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
|
|
1007
1028
|
| `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
|
|
1008
1029
|
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
|
|
1030
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT` so the machine dir survives for diagnosis |
|
|
1031
|
+
| `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
|
|
1032
|
+
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
|
|
1009
1033
|
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
|
|
1010
1034
|
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
|
|
1035
|
+
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
|
|
1011
1036
|
| `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
|
|
1012
1037
|
| `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
|
|
1013
1038
|
| `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
|
|
@@ -1151,8 +1176,12 @@ state deliberately) if a built-in service needs a different host port.
|
|
|
1151
1176
|
|
|
1152
1177
|
`quickchr clean <name>` re-copies the boot image from cache and
|
|
1153
1178
|
recreates extra disks per `machine.json`. Snapshots are gone (they live
|
|
1154
|
-
inside the qcow2 file).
|
|
1155
|
-
|
|
1179
|
+
inside the qcow2 file). Every credential the old disk held is wiped —
|
|
1180
|
+
stored instance credentials, `state.user`, the managed SSH keypair under
|
|
1181
|
+
`<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
|
|
1182
|
+
went with the disk. The machine comes back as a factory CHR: `admin` with
|
|
1183
|
+
an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
|
|
1184
|
+
to afterwards. Nothing is re-provisioned automatically.
|
|
1156
1185
|
|
|
1157
1186
|
### Snapshots
|
|
1158
1187
|
|
|
@@ -1255,6 +1284,9 @@ prevents orphan disks accumulating across attempts.
|
|
|
1255
1284
|
(MNDP) on the host via a `socket-connect` NIC.
|
|
1256
1285
|
- **docs/qga-x86-macos-qemu10-investigation.md** — root cause for the
|
|
1257
1286
|
QGA-on-macOS-arm64 limitation.
|
|
1287
|
+
- **docs/m4-hvf-arm64-investigation.md** — **open** investigation into why arm64
|
|
1288
|
+
CHR panics under HVF on Apple M4 (why arm64 falls back to TCG there); evidence
|
|
1289
|
+
ledger, competing theories, and the test plan to settle them.
|
|
1258
1290
|
- **`.github/instructions/`** — agent-facing rules (testing, qemu,
|
|
1259
1291
|
routeros-rest, provisioning, ci, bun-http, general).
|
|
1260
1292
|
- **`examples/`** — runnable scripts (`bun run`): `quickstart` (single-CHR smoke),
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -157,6 +157,26 @@ export async function resolveTimeoutExtraMs(flags: Record<string, string | boole
|
|
|
157
157
|
return seconds !== undefined ? seconds * 1000 : undefined;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/** Apply an explicit `--accel <auto|tcg|hvf|kvm>` as the process-level accelerator
|
|
161
|
+
* override, so every detectAccel() call in this run (QEMU args, boot timeouts,
|
|
162
|
+
* respawn logic) agrees on one value. Absent flag → the accel setting's own
|
|
163
|
+
* env/file/default tiers apply, so this only installs the top tier. Throws
|
|
164
|
+
* INVALID_SETTING_VALUE on an unrecognized mode. Shared by cmdAdd and cmdStart. */
|
|
165
|
+
export async function applyAccelFlag(flags: Record<string, string | boolean | string[]>): Promise<void> {
|
|
166
|
+
const value = flags.accel;
|
|
167
|
+
if (value === undefined) return;
|
|
168
|
+
const raw = Array.isArray(value)
|
|
169
|
+
? value.at(-1) ?? ""
|
|
170
|
+
: typeof value === "string"
|
|
171
|
+
? value
|
|
172
|
+
: "";
|
|
173
|
+
const [{ parseAccelMode }, { setAccelOverride }] = await Promise.all([
|
|
174
|
+
import("../lib/types.ts"),
|
|
175
|
+
import("../lib/platform.ts"),
|
|
176
|
+
]);
|
|
177
|
+
setAccelOverride(parseAccelMode(raw));
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
/** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
|
|
161
181
|
* only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
|
|
162
182
|
* --all bulk-restart path. */
|
|
@@ -535,6 +555,7 @@ async function cmdAdd(argv: string[]) {
|
|
|
535
555
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
536
556
|
const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
|
|
537
557
|
const { resolveSetting } = await import("../lib/settings.ts");
|
|
558
|
+
await applyAccelFlag(flags);
|
|
538
559
|
|
|
539
560
|
const opts: StartOptions = {
|
|
540
561
|
version: flag(flags, "version"),
|
|
@@ -1175,6 +1196,8 @@ async function cmdSetup() {
|
|
|
1175
1196
|
async function cmdStart(argv: string[]) {
|
|
1176
1197
|
const { flags, positional } = parseFlags(argv);
|
|
1177
1198
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1199
|
+
// Before the --all branch: both start paths must see the same override.
|
|
1200
|
+
await applyAccelFlag(flags);
|
|
1178
1201
|
|
|
1179
1202
|
// --all: start every stopped machine in background
|
|
1180
1203
|
if (flagBool(flags, "all")) {
|
|
@@ -2720,6 +2743,9 @@ Options:
|
|
|
2720
2743
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
2721
2744
|
--cpu <n> vCPU count (default: 1)
|
|
2722
2745
|
--mem <mb> RAM in MB (default: 512)
|
|
2746
|
+
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2747
|
+
Anything but 'auto' bypasses detection, including the
|
|
2748
|
+
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2723
2749
|
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
2724
2750
|
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
2725
2751
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
@@ -2802,6 +2828,9 @@ Options:
|
|
|
2802
2828
|
--name <name> Instance name
|
|
2803
2829
|
--cpu <n> vCPU count (default: 1)
|
|
2804
2830
|
--mem <mb> RAM in MB (default: 512)
|
|
2831
|
+
--accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
|
|
2832
|
+
Anything but 'auto' bypasses detection, including the
|
|
2833
|
+
arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
|
|
2805
2834
|
--boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
|
|
2806
2835
|
--boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
|
|
2807
2836
|
--add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
|
|
@@ -3056,6 +3085,8 @@ Managed keys:
|
|
|
3056
3085
|
default-channel Default --channel for 'add'/'start' and the setup wizard.
|
|
3057
3086
|
stable|long-term|testing|development (default: stable)
|
|
3058
3087
|
default-arch Default --arch. arm64|x86|auto (default: auto = host native)
|
|
3088
|
+
accel QEMU accelerator for 'add'/'start'.
|
|
3089
|
+
auto|tcg|hvf|kvm (default: auto = detect)
|
|
3059
3090
|
cache-max-size Auto-prune cap applied after each successful start.
|
|
3060
3091
|
Size string (e.g. 2G, 512M). (default: 2G)
|
|
3061
3092
|
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
package/src/index.ts
CHANGED
|
@@ -108,6 +108,7 @@ export type { QgaCommandInfo } from "./lib/qga.ts";
|
|
|
108
108
|
|
|
109
109
|
// Console exec utilities
|
|
110
110
|
export { consoleExec, isConsoleReady, stripAnsi } from "./lib/console.ts";
|
|
111
|
+
export type { ConsoleExecResult } from "./lib/console.ts";
|
|
111
112
|
|
|
112
113
|
// Channel utilities
|
|
113
114
|
export { isQgaReady } from "./lib/channels.ts";
|
package/src/lib/channels.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Arch, ChannelTcpEndpoint, QgaCommand } from "./types.ts";
|
|
|
15
15
|
import { QuickCHRError } from "./types.ts";
|
|
16
16
|
import { qgaProbe, qgaRawCommand } from "./qga.ts";
|
|
17
17
|
import { qgaKvmWarning } from "./platform.ts";
|
|
18
|
+
import { newMonitorPhaseTimings, summarizeMonitorPhases } from "./diagnostics.ts";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Resolve the IPC path for a QEMU channel (monitor, serial, qga).
|
|
@@ -87,9 +88,19 @@ export async function monitorCommand(
|
|
|
87
88
|
const endpoint = channelEndpoint(machineDir, "monitor", portBase);
|
|
88
89
|
|
|
89
90
|
return new Promise((resolve, reject) => {
|
|
91
|
+
// Per-phase timings so a timeout says *where* the round-trip stalled rather
|
|
92
|
+
// than just that it did (#80). Only read on the timeout path — a successful
|
|
93
|
+
// command pays nothing but a few Date.now() calls.
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
const phases = newMonitorPhaseTimings();
|
|
96
|
+
const since = () => Date.now() - started;
|
|
97
|
+
|
|
90
98
|
const timeout = setTimeout(() => {
|
|
91
99
|
socket.destroy();
|
|
92
|
-
reject(new QuickCHRError(
|
|
100
|
+
reject(new QuickCHRError(
|
|
101
|
+
"BOOT_TIMEOUT",
|
|
102
|
+
`Monitor command timed out after ${timeoutMs}ms: "${command}" — ${summarizeMonitorPhases(phases)}`,
|
|
103
|
+
));
|
|
93
104
|
}, timeoutMs);
|
|
94
105
|
|
|
95
106
|
let buffer = "";
|
|
@@ -99,13 +110,20 @@ export async function monitorCommand(
|
|
|
99
110
|
? connect({ path: endpoint })
|
|
100
111
|
: connect(endpoint.port, endpoint.host);
|
|
101
112
|
|
|
113
|
+
socket.on("connect", () => { phases.connectedAtMs = since(); });
|
|
114
|
+
|
|
102
115
|
socket.on("data", (data) => {
|
|
116
|
+
phases.bytesReceived += data.length;
|
|
117
|
+
phases.firstByteAtMs ??= since();
|
|
118
|
+
if (sentCommand) phases.responseFirstByteAtMs ??= since();
|
|
103
119
|
buffer += data.toString();
|
|
104
120
|
// Wait for the (qemu) prompt before sending command
|
|
105
121
|
if (!sentCommand && buffer.includes("(qemu)")) {
|
|
106
122
|
sentCommand = true;
|
|
123
|
+
phases.promptAtMs = since();
|
|
107
124
|
buffer = "";
|
|
108
125
|
socket.write(command + "\n");
|
|
126
|
+
phases.commandWrittenAtMs = since();
|
|
109
127
|
} else if (sentCommand && buffer.includes("(qemu)")) {
|
|
110
128
|
// Got response — strip the prompt, the command echo, and ANSI noise
|
|
111
129
|
clearTimeout(timeout);
|
|
@@ -122,7 +140,10 @@ export async function monitorCommand(
|
|
|
122
140
|
if (sentCommand) {
|
|
123
141
|
resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
|
|
124
142
|
} else {
|
|
125
|
-
reject(new QuickCHRError(
|
|
143
|
+
reject(new QuickCHRError(
|
|
144
|
+
"MACHINE_STOPPED",
|
|
145
|
+
`Monitor socket closed before command could be sent — ${summarizeMonitorPhases(phases)}`,
|
|
146
|
+
));
|
|
126
147
|
}
|
|
127
148
|
});
|
|
128
149
|
|