@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 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
- │ └── 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
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 5 managed keys:
104
- `default-channel`, `default-arch`, `cache-max-size`, `timeout-extra`, `secure-login`.
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) | HVF | HVF | Apple Silicon, bun is arm64 |
113
- | macOS arm64 (Rosetta) | HVF | TCG | bun is x86_64; arm64 HVF skipped |
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
- - macOS: checks `kern.hv_support` via sysctl; for arm64 guest additionally checks `process.arch === "arm64"` (native bun = Apple Silicon).
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 always safe, just slower (~20s x86 TCG boot on Apple Silicon; ~2 min arm64 TCG on Intel).
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, deletes
203
- EFI vars (UEFI re-initializes), and clears per-instance credentials.
204
- Provisioning options remain in `machine.json` and re-run on next `start`.
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 other four: the setup wizard's login prompt needs to tell
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 5 keys, resolved value + source
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 5 keys above, and does not
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 | HVF |
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 HVF for arm64 is skipped (architecture mismatch). Run with
935
- native arm64 Bun to get arm64 HVF.
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 5 managed keys are all structural preferences, not secrets.
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). Per-instance credentials are wiped so the next
1155
- boot re-provisions them.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
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";
@@ -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("BOOT_TIMEOUT", "Monitor command timed out"));
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("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
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