@tikoci/quickchr 0.4.6 → 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
@@ -62,6 +62,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
62
62
 
63
63
  **Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)
64
64
 
65
+ 11. **A download is bounded by whether it is *moving*, not by a flat total** — `src/lib/download.ts` is the one download path for both `images.ts` and `packages.ts`. Before it, each caller had its own bound and they disagreed: images aborted at a flat `120_000` per attempt with three retries, packages had **no** deadline and **no** retries, so its only bound was whatever the calling test imposed.
66
+
67
+ A total-duration deadline cannot distinguish *slow* from *stuck*. It fires on a healthy transfer whose only sin is being large, and the retry path then re-downloads from zero — one slow transfer becomes three.
68
+
69
+ **The deadline sat inside the natural variance of a healthy transfer, which is why it was intermittent.** Measured locally 2026-07-31 against `download.mikrotik.com` — same 52.2 MB `all_packages-arm64-7.22.1.zip`, same link, same client, four consecutive attempts:
70
+
71
+ | attempt | elapsed | throughput | under the old flat 120 s? |
72
+ |---|---|---|---|
73
+ | 1 | 118.4 s | 0.421 MB/s | yes, by 1.6 s |
74
+ | 2 | 94.2 s | 0.528 MB/s | yes |
75
+ | 3 | **123.5 s** | 0.403 MB/s | **no — aborted** |
76
+ | 4 | 82.2 s | 0.606 MB/s | yes |
77
+
78
+ All four completed. **One in four exceeded the deadline**, and a fifth attempt through the new streaming path measured 129.4 s (0.385 MB/s) — so the range on one ordinary link is 82–129 s against a 120 s bound. The deadline was inside the distribution, and an unchanged healthy download therefore passed or failed on link jitter alone.
79
+
80
+ That is a sharper statement of the defect than a clean abort would have been, and it is why CI saw "both images needed two retries" *sometimes* rather than always. CI's cold ~0.35 MB/s (→ ~149 s) sits **below** this whole local range, which is why a hosted runner hit it far more reliably than this laptop does — run 30606079288, and B7's local suite where `provisioning.test.ts` spent 619 s of 992 s downloading with zero QEMU processes alive.
81
+
82
+ ⚠️ **Do not restate this as "the old code always aborted that download."** An earlier draft of this entry said so on the strength of a single 129.4 s measurement; the next attempt completed in 101.5 s and disproved it as a general claim. One measurement of an intermittent is a signal, not a fact — the same rule the CI program runs on.
83
+
84
+ **Two deadlines, and the failure says which fired.** Neither alone is sufficient: stall detection alone would let a transfer trickling at one byte per second run forever, and a size-derived budget alone cannot fail a wedge quickly.
85
+ - **Stall** (`DOWNLOAD_STALL_MS`, 30 s) — reset on every received chunk. Bounds *silence*, so a moving transfer is never aborted for being slow.
86
+ - **Transfer budget** — `DOWNLOAD_BUDGET_BASE_MS` (30 s for connect/TLS/headers) plus `content-length ÷ COLD_DOWNLOAD_FLOOR_BYTES_PER_S`, a **floor** throughput of 120 000 B/s ≈ ⅓ of the slowest cold transfer ever measured. The 52.2 MB zip gets 465 s. When the server sends no `content-length` the stated `DOWNLOAD_NO_LENGTH_BUDGET_MS` (15 min) applies and the outcome says so — verified that `download.mikrotik.com` *does* send it on both image and package artifacts, so the size-derived budget is the production path.
87
+
88
+ **Retry policy follows from that split.** `DOWNLOAD_STALLED` is retriable — a wedged socket usually moves on the next attempt. `DOWNLOAD_TOO_SLOW` is **terminal on purpose**: the budget is already ~3× the slowest throughput on record, so retrying re-downloads from zero on a link measured slower than the floor, which is the exact behavior this decision removes. It is a signal that the floor is wrong (or the link genuinely is), not something to paper over with another attempt.
89
+
90
+ **The floor constant has one home.** It lives here and `test/integration/timeouts.ts` re-exports it; `coldDownloadTestTimeout()` is *derived from* `transferBudgetMs()` rather than recomputing it, which makes one of #106's partial orders (`download deadline < test timeout`) structural instead of coincidental. Two constants asked not to drift are how they drift.
91
+
92
+ **Streaming also bounds memory, which the old path did not.** `await response.arrayBuffer()` held the entire artifact in RAM before writing it — 52.2 MB for the largest. Measured: a `Bun.file().writer()` sink writing 40 MiB in 1 MiB chunks keeps the file growing on disk in lockstep with flat RSS (22 MB throughout), so the transient allocation is gone. Recorded as a side effect, **not** as a claim about #76 — a 52 MB transient is not a plausible cause of a runner losing communication, and B8a/B8b should not treat this as having changed that picture.
93
+
94
+ **Partial transfers cannot poison the cache.** Downloads stream to a **per-call** `<dest>.<pid>-<uuid>.part` and are renamed only after a verified transfer. Per-call, not a fixed `<dest>.part`, because both callers guard only with `existsSync(destPath)` — two concurrent downloads of one artifact (two `quickchr start`s, parallel tests) would otherwise interleave writes into one file, publish the corrupt result, and delete each other's in-flight file. The unique suffix removes the collision without deduplicating the work; coalescing concurrent downloads needs a lock at both call sites and is a larger change.
95
+
96
+ Verification is two checks, because either alone leaves a hole: bytes received must equal `content-length` **when one was sent**, and a **zero-byte body is always a failure** regardless of framing. The second exists because Bun's fetch hands back an *unknown-length* response for a malformed `content-length` — measured against a raw stub, `4096.5` and `-5` are passed through as headers but deliver 0 bytes, while a blank value is stripped to `null` — and an unknown-length response has no length to verify against. Without it the empty file would be renamed into the cache and served as a complete artifact forever. No artifact quickchr downloads is empty, so this costs nothing. Both callers gate on `existsSync(zipPath)`, so a truncated file left at the destination would be served as a complete cached artifact forever. Resuming with a `Range` request would be better than either deadline (MikroTik sends `accept-ranges: bytes`) and is deliberately out of scope — this decision bounds and classifies a transfer, it does not change the transport. Covered by `test/unit/download.test.ts`. → [#116](https://github.com/tikoci/quickchr/issues/116)
97
+
98
+ ⚠️ **Testing note.** `Bun.serve` **drops an explicit `content-length` when the body is a `ReadableStream`** and frames the response `transfer-encoding: chunked`, so a stub built on it cannot exercise the size-derived budget at all — every response looks unknown-length. The anchor tests use a raw `Bun.listen` HTTP stub for that reason.
99
+
65
100
  ## Port Layout
66
101
 
67
102
  | Offset | Service | Guest Port |
@@ -102,7 +137,10 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
102
137
  │ ├── serial.sock # Serial console
103
138
  │ ├── qga.sock # QGA (x86 only)
104
139
  │ ├── qemu.pid # PID file
105
- │ └── qemu.log # Output log
140
+ │ ├── qemu.log # Output log
141
+ │ └── serial.log # Serial console tee (only with QUICKCHR_SERIAL_LOG=1)
142
+ └── failures/ # Boot-failure reports (see below)
143
+ └── boot-failure-<machine>-<iso8601>.json
106
144
  ```
107
145
 
108
146
  **Global settings** live separately, under the XDG **config** tier (not the data tree
@@ -189,6 +227,33 @@ incidentally; crediting those suppressed the scheduler for versions no full suit
189
227
  targeted). The per-run files are the source of truth; `ci-metrics refold` rebuilds the
190
228
  rollup after fold-logic changes.
191
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
+
192
257
  **Merge policy**: squash-only, PR title → main commit subject (write PR titles as
193
258
  conventional commits), PR body → commit body, branches auto-delete. Review threads must
194
259
  be resolved before merge (`required_conversation_resolution`) and automated reviews must
@@ -204,6 +269,166 @@ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inpu
204
269
  - `{integration|main|sweep}-logs-<platform>` — bun test output + timing + metrics.ndjson +
205
270
  machine.json + qemu.log (7 days); durable timing lives on the `ci-data` branch
206
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
+
207
432
  ## Design Principles
208
433
 
209
434
  ### Scope Boundary — QEMU Expert, Not Orchestrator
@@ -313,6 +538,21 @@ Provisioning via REST API is preferred (simple HTTP calls). Serial console provi
313
538
 
314
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.
315
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
+
316
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`.
317
557
 
318
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
 
@@ -437,6 +445,9 @@ mutate any machine's `machine.json`.
437
445
  | `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
438
446
  | `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
439
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` |
440
451
  | `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
441
452
  | `NO_COLOR` | Disable ANSI styling |
442
453
 
@@ -1016,6 +1027,9 @@ the 6 managed keys are all structural preferences, not secrets.
1016
1027
  | `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
1017
1028
  | `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
1018
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 |
1019
1033
  | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
1020
1034
  | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
1021
1035
  | `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
@@ -1162,8 +1176,12 @@ state deliberately) if a built-in service needs a different host port.
1162
1176
 
1163
1177
  `quickchr clean <name>` re-copies the boot image from cache and
1164
1178
  recreates extra disks per `machine.json`. Snapshots are gone (they live
1165
- inside the qcow2 file). Per-instance credentials are wiped so the next
1166
- 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.
1167
1185
 
1168
1186
  ### Snapshots
1169
1187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.6",
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/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