@tikoci/quickchr 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Boot / channel failure forensics.
3
+ *
4
+ * Exists because the `ci:slow-platform-flake` series (#76 #79 #80 #91) kept
5
+ * producing failures that carried no evidence: `waitForBoot()` returns a bare
6
+ * boolean, and both BOOT_TIMEOUT call sites `stop()` + `remove()` the machine
7
+ * before throwing, so the only surviving artefact was `qemu.log.slice(-1200)` —
8
+ * which on every recorded failure contained nothing but the cleanup SIGTERM.
9
+ *
10
+ * Two things are captured here:
11
+ *
12
+ * 1. **What the REST probe saw.** A boot that never becomes REST-ready is
13
+ * ambiguous: connection-refused for the whole window means the hostfwd
14
+ * never bound (a host-side / QEMU-args problem), while socket hangs or
15
+ * resets mean the guest is answering at TCP level but RouterOS is not up.
16
+ * `BootProbeStats` keeps that distinction instead of collapsing it to
17
+ * `false`.
18
+ *
19
+ * 2. **The machine's state at the moment of failure**, before cleanup runs —
20
+ * full qemu.log, monitor `info status` (a paused/io-error guest is an
21
+ * instant answer), QEMU liveness, a direct TCP probe of the forwarded
22
+ * port, the argv, and the machine-dir listing.
23
+ *
24
+ * 3. **Where the drop happened**, added for #105 because (1) and (2) proved
25
+ * only *that* #79's boot went silent. Three instruments, in increasing
26
+ * order of invasiveness: per-port slirp classification
27
+ * ({@link probeForwardedPorts}), a read-only guest snapshot over the
28
+ * serial console (guest-snapshot.ts), and — opt-in — the counting-rule
29
+ * probe that says whether the guest received the SYNs at all.
30
+ *
31
+ * All of it is best-effort and must never turn a diagnostic problem into a
32
+ * thrown error: every capture path swallows its own failures and records them
33
+ * as text.
34
+ */
35
+
36
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
37
+ import { connect } from "node:net";
38
+ import { join } from "node:path";
39
+ import {
40
+ captureGuestSnapshot,
41
+ deepBootDiagnosticsEnabled,
42
+ runCountingRuleProbe,
43
+ type CountingRuleProbe,
44
+ type GuestExec,
45
+ type GuestSnapshot,
46
+ } from "./guest-snapshot.ts";
47
+ import type { PortMapping } from "./types.ts";
48
+
49
+ /** Classification of a single REST readiness probe. */
50
+ export type BootProbeOutcome =
51
+ /** TCP connect refused — nothing is listening on the forwarded host port. */
52
+ | "refused"
53
+ /** Connection reset by peer — something accepted then dropped us. */
54
+ | "reset"
55
+ /** Connect or response exceeded the per-probe budget — socket hung. */
56
+ | "probe-timeout"
57
+ /** 401/403 — REST answered; auth layer is up. */
58
+ | "unauthorized"
59
+ /** 2xx but not the expected `board-name` singleton — REST still initializing. */
60
+ | "wrong-body"
61
+ /** Any other HTTP status. */
62
+ | "http-error"
63
+ /** 2xx with the expected body. */
64
+ | "ready"
65
+ /** Anything unclassified — `detail` carries the raw text. */
66
+ | "other";
67
+
68
+ export interface BootProbeStats {
69
+ /** Total probe attempts made. */
70
+ attempts: number;
71
+ /** Attempt count per outcome. */
72
+ counts: Partial<Record<BootProbeOutcome, number>>;
73
+ /** ms from the start of the wait to the first probe that produced any HTTP
74
+ * status at all (i.e. the forwarded port was live). Null if never. */
75
+ firstHttpAtMs: number | null;
76
+ /** Outcome + detail of the very first probe. */
77
+ first: { atMs: number; outcome: BootProbeOutcome; detail?: string } | null;
78
+ /** Outcome + detail of the most recent probe. */
79
+ last: { atMs: number; outcome: BootProbeOutcome; detail?: string } | null;
80
+ }
81
+
82
+ export function newBootProbeStats(): BootProbeStats {
83
+ return { attempts: 0, counts: {}, firstHttpAtMs: null, first: null, last: null };
84
+ }
85
+
86
+ /** Map a thrown probe error to an outcome. Keep in sync with restGet()'s
87
+ * rejection paths: node errno codes, plus its own "restGet timeout" message. */
88
+ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; detail: string } {
89
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
92
+ if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
93
+ if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
94
+ return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
95
+ }
96
+ return { outcome: "other", detail: code ? `${code}: ${message}` : message };
97
+ }
98
+
99
+ export function recordBootProbe(
100
+ stats: BootProbeStats | undefined,
101
+ elapsedMs: number,
102
+ outcome: BootProbeOutcome,
103
+ detail?: string,
104
+ ): void {
105
+ if (!stats) return;
106
+ stats.attempts++;
107
+ stats.counts[outcome] = (stats.counts[outcome] ?? 0) + 1;
108
+ const entry = { atMs: elapsedMs, outcome, ...(detail ? { detail } : {}) };
109
+ stats.first ??= entry;
110
+ stats.last = entry;
111
+ const sawHttp = outcome === "unauthorized" || outcome === "wrong-body" || outcome === "http-error" || outcome === "ready";
112
+ if (sawHttp && stats.firstHttpAtMs === null) stats.firstHttpAtMs = elapsedMs;
113
+ }
114
+
115
+ /** One-line human summary — this is what makes a BOOT_TIMEOUT actionable.
116
+ * `probe-timeout` throughout means slirp accepted but nothing behind it answered
117
+ * (guest-side); `refused` means QEMU never bound the port at all (host-side);
118
+ * `reset` / `wrong-body` mean the guest answered and RouterOS is still settling.
119
+ * See probeTcpPort() for why `refused` is rarer than intuition suggests. */
120
+ export function summarizeBootProbe(stats: BootProbeStats): string {
121
+ if (stats.attempts === 0) return "no probes recorded";
122
+ const tally = Object.entries(stats.counts)
123
+ .sort((a, b) => b[1] - a[1])
124
+ .map(([k, v]) => `${k}=${v}`)
125
+ .join(" ");
126
+ const reachedHttp = stats.firstHttpAtMs === null
127
+ ? "forwarded port never produced an HTTP response"
128
+ : `first HTTP response at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
129
+ const last = stats.last
130
+ ? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
131
+ : "";
132
+ return `${stats.attempts} probes [${tally}] — ${reachedHttp}; ${last}`;
133
+ }
134
+
135
+ /** True when failure paths should keep the machine directory instead of
136
+ * removing it — for poking at a wedged machine by hand (`quickchr console`,
137
+ * the disk image, the sockets).
138
+ *
139
+ * A local debugging aid, **not** how evidence reaches CI: `captureBootFailure()`
140
+ * writes its report outside the machine dir and embeds the logs, so the report
141
+ * survives cleanup either way. CI therefore leaves this unset — stranded
142
+ * machines hold ~130-190 MB and a port block each, which risks tripping
143
+ * test/preload.ts's storage preflight on a full matrix. */
144
+ export function preserveOnFailure(): boolean {
145
+ return process.env.QUICKCHR_PRESERVE_ON_FAILURE === "1";
146
+ }
147
+
148
+ /** True when QEMU should tee the serial console to `<machineDir>/serial.log`.
149
+ * Opt-in: serial-console provisioning types the generated user password in
150
+ * cleartext, so the log is secret-bearing (see provision.ts provisionViaConsole). */
151
+ export function serialLogEnabled(): boolean {
152
+ return process.env.QUICKCHR_SERIAL_LOG === "1";
153
+ }
154
+
155
+ /** Is a TCP port on localhost accepting connections right now?
156
+ *
157
+ * Useful mainly in the negative. Under the default `user` (slirp) network mode
158
+ * QEMU's hostfwd listens for the whole life of the process and accepts before it
159
+ * ever tries to reach the guest, so a completely dead guest still reports
160
+ * "accepting" — verified locally 2026-07-27 with a deliberately 32 MB CHR.
161
+ * A refusal, though, means QEMU itself is gone or never bound the port. */
162
+ export async function probeTcpPort(port: number, timeoutMs = 2000): Promise<string> {
163
+ return new Promise((resolve) => {
164
+ let settled = false;
165
+ const finish = (result: string) => {
166
+ if (settled) return;
167
+ settled = true;
168
+ socket.destroy();
169
+ resolve(result);
170
+ };
171
+ const socket = connect(port, "127.0.0.1");
172
+ socket.setTimeout(timeoutMs);
173
+ socket.on("connect", () => finish("accepting"));
174
+ socket.on("timeout", () => finish("connect timed out"));
175
+ socket.on("error", (err) => finish((err as NodeJS.ErrnoException).code ?? err.message));
176
+ });
177
+ }
178
+
179
+ // --- Per-port slirp classification ---
180
+
181
+ /** One row of QEMU's `info usernet` table:
182
+ *
183
+ * ```text
184
+ * Protocol[State] FD Source Address Port Dest. Address Port RecvQ SendQ
185
+ * TCP[HOST_FORWARD] 21 * 9170 10.0.2.15 80 0 0
186
+ * TCP[SYN_SENT] 103 127.0.0.1 9100 10.0.2.15 80 0 0
187
+ * UDP[223 sec] 168 10.0.2.15 5678 255.255.255.255 5678 0 0
188
+ * ```
189
+ *
190
+ * The bracketed state can contain a space (UDP rows carry a TTL, `[223 sec]`),
191
+ * so it is matched as "anything but `]`" rather than a word. */
192
+ export interface UsernetRow {
193
+ proto: string;
194
+ state: string;
195
+ fd: number;
196
+ srcAddr: string;
197
+ srcPort: number;
198
+ dstAddr: string;
199
+ dstPort: number;
200
+ recvQ: number;
201
+ sendQ: number;
202
+ }
203
+
204
+ const USERNET_ROW_RE =
205
+ /^\s*(TCP|UDP)\[([^\]]*)\]\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$/;
206
+
207
+ export function parseUsernetTable(text: string): UsernetRow[] {
208
+ const rows: UsernetRow[] = [];
209
+ for (const line of text.split(/\r?\n/)) {
210
+ const m = USERNET_ROW_RE.exec(line);
211
+ if (!m) continue;
212
+ rows.push({
213
+ proto: String(m[1]),
214
+ state: String(m[2]),
215
+ fd: Number(m[3]),
216
+ srcAddr: String(m[4]),
217
+ srcPort: Number(m[5]),
218
+ dstAddr: String(m[6]),
219
+ dstPort: Number(m[7]),
220
+ recvQ: Number(m[8]),
221
+ sendQ: Number(m[9]),
222
+ });
223
+ }
224
+ return rows;
225
+ }
226
+
227
+ /** What a probe of one forwarded port revealed about the guest behind it.
228
+ *
229
+ * Grounded on a healthy local 7.21.5 CHR (x86/HVF), four probes per condition
230
+ * — see tikoci/quickchr#79 (comment 5109767102) and #105:
231
+ *
232
+ * | guest condition | `info usernet` after probing |
233
+ * |-------------------------------------|------------------------------|
234
+ * | serving (`www` enabled) | `TIME_WAIT` rows |
235
+ * | nothing listening (service off) | **no rows at all** — the guest RSTs and slirp discards the entry |
236
+ * | silently dropped (`action=drop`) | `SYN_SENT` rows, persisting |
237
+ *
238
+ * A host-side TCP connect reads "accepting" in all three cases (slirp's
239
+ * hostfwd accepts regardless of guest state), which is why `hostfwd.tcpConnect`
240
+ * in the report is not evidence and this table is. */
241
+ export type ForwardOutcome =
242
+ /** Connection completed — something in the guest answered on this port. */
243
+ | "served"
244
+ /** No lingering row: the guest sent an RST, so nothing is listening on that
245
+ * port — but the guest itself is alive and answering. */
246
+ | "refused"
247
+ /** `SYN_SENT` persisting: the SYNs are being silently dropped. */
248
+ | "dropped"
249
+ /** No `HOST_FORWARD` row — QEMU never bound this forward. */
250
+ | "not-forwarded"
251
+ /** Rows exist but in no state we classify. */
252
+ | "unknown";
253
+
254
+ export interface ForwardClassification {
255
+ name: string;
256
+ hostPort: number;
257
+ guestPort: number;
258
+ proto: string;
259
+ outcome: ForwardOutcome;
260
+ /** Non-`HOST_FORWARD` states seen for this guest port, e.g. `["SYN_SENT×15"]`. */
261
+ states: string[];
262
+ /** Result of the host-side TCP connect, kept for completeness. */
263
+ hostConnect: string;
264
+ }
265
+
266
+ const SERVED_STATES = ["ESTABLISHED", "TIME_WAIT", "FIN_WAIT_1", "FIN_WAIT_2", "CLOSE_WAIT", "LAST_ACK", "CLOSING"];
267
+
268
+ export function classifyForwardedPorts(
269
+ rows: UsernetRow[],
270
+ forwards: { name: string; host: number; guest: number; proto: string }[],
271
+ hostConnect: Record<string, string> = {},
272
+ ): ForwardClassification[] {
273
+ // Two forwards may target the same guest port from different host ports, so the
274
+ // guest port alone cannot attribute a row. slirp reports the *host* forward
275
+ // port as a forwarded connection's source port, not the client's ephemeral one
276
+ // — verified on captured output, where every live row sat at
277
+ // `127.0.0.1:<hostPort> -> 10.0.2.15:<guestPort>`, and the same shape appears
278
+ // in #79's CI dumps. Confirm that convention holds in this dump before relying
279
+ // on it: if it ever stops, classification degrades to guest-port matching
280
+ // rather than silently reporting every port as `refused`.
281
+ const liveRowsCarryHostPort = rows.some(
282
+ (r) => r.state !== "HOST_FORWARD" && forwards.some((f) => f.host === r.srcPort),
283
+ );
284
+
285
+ return forwards.map((fwd) => {
286
+ const byGuestPort = rows.filter((r) => r.proto.toUpperCase() === fwd.proto.toUpperCase() && r.dstPort === fwd.guest);
287
+ const forwarded = byGuestPort.some((r) => r.state === "HOST_FORWARD" && r.srcPort === fwd.host);
288
+ const liveRows = byGuestPort.filter((r) => r.state !== "HOST_FORWARD");
289
+ const live = liveRowsCarryHostPort ? liveRows.filter((r) => r.srcPort === fwd.host) : liveRows;
290
+
291
+ const tally = new Map<string, number>();
292
+ for (const r of live) tally.set(r.state, (tally.get(r.state) ?? 0) + 1);
293
+ const states = [...tally].map(([state, n]) => (n > 1 ? `${state}×${n}` : state));
294
+
295
+ let outcome: ForwardOutcome;
296
+ if (!forwarded) outcome = "not-forwarded";
297
+ else if (live.some((r) => r.state === "SYN_SENT")) outcome = "dropped";
298
+ else if (live.length === 0) outcome = "refused";
299
+ else if (live.some((r) => SERVED_STATES.includes(r.state))) outcome = "served";
300
+ else outcome = "unknown";
301
+
302
+ return {
303
+ name: fwd.name,
304
+ hostPort: fwd.host,
305
+ guestPort: fwd.guest,
306
+ proto: fwd.proto,
307
+ outcome,
308
+ states,
309
+ hostConnect: hostConnect[fwd.name] ?? "not probed",
310
+ };
311
+ });
312
+ }
313
+
314
+ /** Read the classification table as one sentence. All ports dropping points at
315
+ * the RX path (nothing the guest exposes is reachable); a single dropped port
316
+ * next to healthy ones points at that one service. */
317
+ export function summarizeForwardOutcomes(ports: ForwardClassification[]): string {
318
+ if (ports.length === 0) return "no TCP forwards to classify";
319
+ const dropped = ports.filter((p) => p.outcome === "dropped");
320
+ const table = ports.map((p) => `${p.name}:${p.guestPort}=${p.outcome}`).join(" ");
321
+ if (dropped.length === 0) return `${table} — no silent drop observed`;
322
+ if (dropped.length === ports.length) {
323
+ return `${table} — EVERY forwarded port is silently dropped: whole-stack silence, points at the guest RX path`;
324
+ }
325
+ return `${table} — ${dropped.map((p) => p.name).join(",")} silently dropped while others are not: service-specific`;
326
+ }
327
+
328
+ export interface ForwardProbeResult {
329
+ probesPerPort: number;
330
+ ports: ForwardClassification[];
331
+ /** Raw `info usernet` taken *after* the probes — the classification input. */
332
+ usernetAfterProbe: string;
333
+ summary: string;
334
+ }
335
+
336
+ /**
337
+ * Probe every forwarded TCP port and classify what slirp saw.
338
+ *
339
+ * The probes are what make the table meaningful: a `refused` verdict only means
340
+ * "nothing listening" because a probe was sent and left no row behind. Fired in
341
+ * parallel — rows are attributed by destination port, not by time.
342
+ */
343
+ export async function probeForwardedPorts(
344
+ forwards: { name: string; host: number; guest: number; proto: string }[],
345
+ monitorQuery: (command: string) => Promise<string>,
346
+ opts: { probesPerPort?: number; settleMs?: number } = {},
347
+ ): Promise<ForwardProbeResult> {
348
+ const probesPerPort = opts.probesPerPort ?? 2;
349
+ const tcp = forwards.filter((f) => f.proto.toLowerCase() === "tcp");
350
+
351
+ const hostConnect: Record<string, string> = {};
352
+ await Promise.all(
353
+ tcp.map(async (fwd) => {
354
+ let last = "not probed";
355
+ for (let i = 0; i < probesPerPort; i++) last = await probeTcpPort(fwd.host);
356
+ hostConnect[fwd.name] = last;
357
+ }),
358
+ );
359
+
360
+ // Let the guest's RSTs land (which removes `refused` rows) before sampling;
361
+ // `SYN_SENT` and `TIME_WAIT` both persist well past this.
362
+ await Bun.sleep(opts.settleMs ?? 1000);
363
+
364
+ let usernet: string;
365
+ try {
366
+ usernet = await monitorQuery("info usernet");
367
+ } catch (e) {
368
+ usernet = `<failed: ${e instanceof Error ? e.message : String(e)}>`;
369
+ }
370
+
371
+ const ports = classifyForwardedPorts(parseUsernetTable(usernet), tcp, hostConnect);
372
+ return { probesPerPort, ports, usernetAfterProbe: usernet, summary: summarizeForwardOutcomes(ports) };
373
+ }
374
+
375
+ function isProcessAlive(pid: number | undefined): string {
376
+ if (pid === undefined) return "unknown (no pid recorded)";
377
+ try {
378
+ process.kill(pid, 0);
379
+ return `alive (pid ${pid})`;
380
+ } catch (e) {
381
+ const code = (e as NodeJS.ErrnoException).code;
382
+ if (code === "EPERM") return `alive but not signalable (pid ${pid})`;
383
+ return `not running (pid ${pid})`;
384
+ }
385
+ }
386
+
387
+ function listMachineDir(machineDir: string): { name: string; bytes: number }[] {
388
+ try {
389
+ return readdirSync(machineDir).map((name) => {
390
+ try {
391
+ return { name, bytes: statSync(join(machineDir, name)).size };
392
+ } catch {
393
+ return { name, bytes: -1 };
394
+ }
395
+ });
396
+ } catch {
397
+ return [];
398
+ }
399
+ }
400
+
401
+ /** Cap on embedded log text. A wedged QEMU can emit megabytes of repeated
402
+ * warnings; the tail is what matters and the report has to stay artifact-sized. */
403
+ const MAX_EMBEDDED_LOG_BYTES = 256 * 1024;
404
+
405
+ function readIfPresent(path: string): string | null {
406
+ try {
407
+ if (!existsSync(path)) return null;
408
+ const text = readFileSync(path, "utf-8");
409
+ return text.length > MAX_EMBEDDED_LOG_BYTES
410
+ ? `<truncated to last ${MAX_EMBEDDED_LOG_BYTES} bytes of ${text.length}>\n${text.slice(-MAX_EMBEDDED_LOG_BYTES)}`
411
+ : text;
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ /** Keep only the newest N reports in a failures directory.
418
+ *
419
+ * Each report embeds up to 2 × 256 KB of log text, and a flaky-boot loop can
420
+ * produce one per attempt, so this directory would otherwise grow without
421
+ * bound — the image cache has `autoPruneIfOverCap`, this had nothing.
422
+ * Best-effort: never throws. */
423
+ export const MAX_BOOT_FAILURE_REPORTS = 20;
424
+
425
+ /** Report filename prefixes, one per capture kind. The cap is on the *directory*,
426
+ * not per prefix: a machine that fails post-readiness is exactly as likely to be
427
+ * retried in a loop as one that fails to boot, and two independent 20-file caps
428
+ * in one directory is a 40-file directory nobody chose. Adding a kind here is
429
+ * what keeps it prunable — a prefix absent from this list grows without bound. */
430
+ export const FAILURE_REPORT_PREFIXES = ["boot-failure-", "post-readiness-failure-"] as const;
431
+
432
+ /** The ISO-8601 stamp `captureBootFailure()` appends, with `:` and `.` replaced
433
+ * by `-`. Ordering is by this, not by the whole filename: sorting whole names
434
+ * was chronological only while every report shared one prefix, and would now
435
+ * rank every `post-readiness-failure-` above every `boot-failure-` regardless
436
+ * of age — silently pruning the newer of the two kinds. */
437
+ const REPORT_STAMP = /(\d{4}-\d{2}-\d{2}T[\d-]+Z)\.json$/;
438
+
439
+ export function pruneBootFailureReports(dir: string, keep = MAX_BOOT_FAILURE_REPORTS): void {
440
+ try {
441
+ const reports = readdirSync(dir)
442
+ .filter((name) => FAILURE_REPORT_PREFIXES.some((p) => name.startsWith(p)) && name.endsWith(".json"))
443
+ // Unstamped names sort last and are pruned first: this function only
444
+ // ever sees names it wrote, so one without a stamp is unrecognized, and
445
+ // keeping it in preference to a dated report would be a guess.
446
+ .sort((a, b) => (REPORT_STAMP.exec(a)?.[1] ?? "").localeCompare(REPORT_STAMP.exec(b)?.[1] ?? ""))
447
+ .reverse();
448
+ for (const stale of reports.slice(keep)) {
449
+ try { unlinkSync(join(dir, stale)); } catch { /* ignore */ }
450
+ }
451
+ } catch { /* ignore */ }
452
+ }
453
+
454
+ /**
455
+ * Worst-case wall clock `captureBootFailure()` may consume, in ms.
456
+ *
457
+ * It runs *after* a boot budget has already been spent, so anything waiting on
458
+ * that call — most importantly a test's own timeout — has to allow for it, or
459
+ * the forensics are killed before they can be written and the failure is
460
+ * evidence-free again (tikoci/quickchr#106). Sum of the bounded parts: forward
461
+ * probe ≤ 20 s, guest snapshot ≤ 60 s (GUEST_SNAPSHOT_BUDGET_MS), counting-rule
462
+ * probe ≤ 90 s, report write and cleanup the rest.
463
+ */
464
+ export const BOOT_FORENSICS_BUDGET_MS = 180_000;
465
+
466
+ /**
467
+ * Why the capture ran, when the trigger was *not* a boot timeout.
468
+ *
469
+ * Boot-timeout captures explain themselves: `BootProbeStats` says what every
470
+ * readiness probe saw, and the whole window is the evidence. A failure *after*
471
+ * readiness has no such record — `restProbe` is null, the machine booted fine,
472
+ * and the interesting facts are which single request died and what state change
473
+ * immediately preceded it. That is #69: a connection accepted and reset on the
474
+ * first request following a credential transition. Without this field the report
475
+ * would show a healthy machine and never say what was being asked of it.
476
+ */
477
+ export interface FailureTrigger {
478
+ /** The operation that failed, e.g. `GET /rest/system/resource`. */
479
+ operation: string;
480
+ /** The error as thrown, message and code. */
481
+ error: string;
482
+ /** ms from REST-ready to the failure, when derivable. Distinguishes a reset
483
+ * seconds after boot from one many operations later. */
484
+ sinceReadyMs?: number;
485
+ /** The credential/database change immediately preceding the failed request —
486
+ * #69's suspected precondition. Free text, e.g.
487
+ * `createUser(testuser) then auth as testuser`. Callers state what they did;
488
+ * nothing here infers it. */
489
+ credentialTransition?: string;
490
+ }
491
+
492
+ export interface BootFailureContext {
493
+ name: string;
494
+ machineDir: string;
495
+ arch: string;
496
+ accel: string;
497
+ /** Omitted by non-boot captures, which have no boot budget to report. */
498
+ bootTimeoutMs?: number;
499
+ pid?: number;
500
+ httpPort?: number;
501
+ portBase?: number;
502
+ qemuArgs?: string[];
503
+ probe?: BootProbeStats;
504
+ /** Free-text marker for which call path failed, e.g. "start" / "_launchExisting". */
505
+ phase: string;
506
+ /** Set by post-readiness captures. See {@link FailureTrigger}. */
507
+ trigger?: FailureTrigger;
508
+ /** Durable directory for the report, OUTSIDE the machine directory.
509
+ *
510
+ * It must outlive the machine: integration tests wrap their bodies in
511
+ * `finally { cleanupMachine(name) }`, which calls `remove()` and deletes the
512
+ * machine dir — so a report written next to qemu.log is gone before CI can
513
+ * upload it, whether or not QUICKCHR_PRESERVE_ON_FAILURE is set. The report
514
+ * embeds the log contents rather than pointing at them, so it stands alone.
515
+ * Defaults to the machine dir when omitted. */
516
+ reportDir?: string;
517
+ /** Runs `info status` / `info block` against the QEMU monitor. Injected so
518
+ * this module stays free of a channels.ts import cycle. */
519
+ monitorQuery?: (command: string) => Promise<string>;
520
+ /** The machine's port forwards, for per-port slirp classification. Without
521
+ * them the report can only say *that* the drop happened, not *where*. */
522
+ forwards?: PortMapping[];
523
+ /** Runs one RouterOS command over the serial console. Injected (console.ts)
524
+ * so this module keeps no channel dependency. Omit to skip guest-side
525
+ * capture entirely — e.g. when no serial channel exists. */
526
+ guestExec?: GuestExec;
527
+ /** Account `guestExec` logs in as. Recorded in the report only. */
528
+ guestUser?: string;
529
+ }
530
+
531
+ export interface BootFailureReport {
532
+ /** Compact multi-line text suitable for embedding in the thrown error. */
533
+ summary: string;
534
+ /** Path of the written JSON report, if the machine dir was writable. */
535
+ reportPath: string | null;
536
+ /** Whether the caller should skip removing the machine directory. */
537
+ preserved: boolean;
538
+ }
539
+
540
+ /**
541
+ * Snapshot everything that explains a boot failure, *before* the caller tears
542
+ * the machine down. Never throws.
543
+ */
544
+ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootFailureReport> {
545
+ const qemuLog = readIfPresent(join(ctx.machineDir, "qemu.log"));
546
+ const serialLog = readIfPresent(join(ctx.machineDir, "serial.log"));
547
+
548
+ const monitor: Record<string, string> = {};
549
+ if (ctx.monitorQuery) {
550
+ // `info usernet` dumps slirp's VLAN state and hostfwd table. It is the one
551
+ // query that separates "the guest is up but unreachable" from "the guest is
552
+ // down": under user-mode networking the host port always accepts (see
553
+ // probeTcpPort), so the forwarding table is the only host-side view of
554
+ // whether slirp can actually deliver to the guest.
555
+ for (const command of ["info status", "info block", "info chardev", "info usernet", "info network"]) {
556
+ try {
557
+ monitor[command] = await ctx.monitorQuery(command);
558
+ } catch (e) {
559
+ monitor[command] = `<failed: ${e instanceof Error ? e.message : String(e)}>`;
560
+ }
561
+ }
562
+ }
563
+
564
+ // Host-side localization: probe every forwarded port and read slirp's own
565
+ // view of what happened to each. This is the only host-side instrument that
566
+ // distinguishes "nothing listening" from "silently dropped" — see
567
+ // ForwardOutcome.
568
+ let forwardProbe: ForwardProbeResult | null = null;
569
+ if (ctx.monitorQuery && ctx.forwards?.length) {
570
+ try {
571
+ forwardProbe = await probeForwardedPorts(ctx.forwards, ctx.monitorQuery);
572
+ } catch (e) {
573
+ forwardProbe = {
574
+ probesPerPort: 0,
575
+ ports: [],
576
+ usernetAfterProbe: "",
577
+ summary: `<forward probe failed: ${e instanceof Error ? e.message : String(e)}>`,
578
+ };
579
+ }
580
+ }
581
+
582
+ // Guest-side: read-only, and reachable even when REST is dead.
583
+ let guest: GuestSnapshot | null = null;
584
+ if (ctx.guestExec) {
585
+ try {
586
+ guest = await captureGuestSnapshot(ctx.guestExec, ctx.guestUser);
587
+ } catch { /* captureGuestSnapshot swallows its own failures; belt and braces */ }
588
+ }
589
+
590
+ // The discriminator, opt-in because it writes to the guest. Skipped when the
591
+ // caller wants the machine preserved — that flag means "leave the state alone".
592
+ let countingRule: CountingRuleProbe | null = null;
593
+ if (ctx.guestExec && ctx.httpPort !== undefined) {
594
+ if (guest && !guest.consoleReachable) {
595
+ countingRule = { ran: false, skipped: "serial console unreachable", detail: "" };
596
+ } else if (!deepBootDiagnosticsEnabled()) {
597
+ countingRule = { ran: false, skipped: "QUICKCHR_DEEP_BOOT_DIAGNOSTICS is not 1", detail: "" };
598
+ } else if (preserveOnFailure()) {
599
+ countingRule = { ran: false, skipped: "QUICKCHR_PRESERVE_ON_FAILURE=1 — guest left untouched", detail: "" };
600
+ } else {
601
+ const httpGuestPort = ctx.forwards?.find((f) => f.host === ctx.httpPort)?.guest ?? 80;
602
+ try {
603
+ countingRule = await runCountingRuleProbe(ctx.guestExec, probeTcpPort, {
604
+ guestPort: httpGuestPort,
605
+ hostPort: ctx.httpPort,
606
+ });
607
+ } catch (e) {
608
+ countingRule = { ran: false, detail: `counting-rule probe failed: ${e instanceof Error ? e.message : String(e)}` };
609
+ }
610
+ }
611
+ }
612
+
613
+ const record = {
614
+ capturedAt: new Date().toISOString(),
615
+ phase: ctx.phase,
616
+ trigger: ctx.trigger ?? null,
617
+ machine: {
618
+ name: ctx.name,
619
+ arch: ctx.arch,
620
+ accel: ctx.accel,
621
+ bootTimeoutMs: ctx.bootTimeoutMs ?? null,
622
+ machineDir: ctx.machineDir,
623
+ portBase: ctx.portBase,
624
+ httpPort: ctx.httpPort,
625
+ },
626
+ host: {
627
+ platform: process.platform,
628
+ hostArch: process.arch,
629
+ qemuProcess: isProcessAlive(ctx.pid),
630
+ },
631
+ restProbe: ctx.probe
632
+ ? { summary: summarizeBootProbe(ctx.probe), ...ctx.probe }
633
+ : null,
634
+ hostfwd: ctx.httpPort === undefined
635
+ ? null
636
+ : { port: ctx.httpPort, tcpConnect: await probeTcpPort(ctx.httpPort) },
637
+ forwardProbe,
638
+ guest,
639
+ countingRule,
640
+ monitor,
641
+ qemuArgs: ctx.qemuArgs ?? null,
642
+ machineDirListing: listMachineDir(ctx.machineDir),
643
+ qemuLog: qemuLog ?? "<absent>",
644
+ serialLog: serialLog ?? (serialLogEnabled() ? "<absent>" : "<not captured — set QUICKCHR_SERIAL_LOG=1>"),
645
+ };
646
+
647
+ let reportPath: string | null = null;
648
+ try {
649
+ const dir = ctx.reportDir ?? ctx.machineDir;
650
+ mkdirSync(dir, { recursive: true });
651
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
652
+ // The kind is in the filename, not only in the JSON: these land in a CI
653
+ // artifact as a flat directory listing, and "why is there a boot-failure
654
+ // report for a machine that booted?" is a question the name should not
655
+ // provoke. Both prefixes are in FAILURE_REPORT_PREFIXES so both prune.
656
+ const prefix = ctx.trigger ? "post-readiness-failure" : "boot-failure";
657
+ reportPath = join(dir, `${prefix}-${ctx.name}-${stamp}.json`);
658
+ writeFileSync(reportPath, `${JSON.stringify(record, null, 2)}\n`);
659
+ pruneBootFailureReports(dir);
660
+ } catch {
661
+ reportPath = null;
662
+ }
663
+
664
+ const lines: string[] = [];
665
+ // First, because on a post-readiness capture everything below it describes a
666
+ // machine that is working — the trigger is the only line that says what broke.
667
+ if (ctx.trigger) {
668
+ const since = ctx.trigger.sinceReadyMs !== undefined
669
+ ? ` at +${(ctx.trigger.sinceReadyMs / 1000).toFixed(1)}s after REST-ready`
670
+ : "";
671
+ lines.push(`Failed after readiness: ${ctx.trigger.operation}${since} — ${ctx.trigger.error}`);
672
+ if (ctx.trigger.credentialTransition) {
673
+ lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
674
+ }
675
+ }
676
+ if (ctx.probe) lines.push(`REST probe: ${summarizeBootProbe(ctx.probe)}`);
677
+ lines.push(`QEMU process: ${record.host.qemuProcess}`);
678
+ if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
679
+ if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);
680
+ // Shapes only — the values stay in the report. See summarizeGuestSnapshot.
681
+ if (guest) lines.push(guest.summary);
682
+ if (countingRule?.ran) lines.push(`counting rule: ${countingRule.detail}`);
683
+ for (const [command, output] of Object.entries(monitor)) {
684
+ if (command === "info status") lines.push(`monitor ${command}: ${output.replace(/\s+/g, " ").trim()}`);
685
+ }
686
+ // serial.log is NOT inlined here, only pointed at. This summary is appended to
687
+ // a thrown QuickCHRError, which the CLI prints and CI echoes into job logs —
688
+ // public ones, on a public repo. The log itself is secret-bearing (console
689
+ // provisioning types the generated password in cleartext) and `logappend=on`
690
+ // means it spans every boot of that machine dir, so an unrelated later failure
691
+ // could print a password typed sessions earlier. The full text stays in the
692
+ // JSON report, which is opt-in and lands in an access-controlled artifact.
693
+ if (serialLog) {
694
+ lines.push(
695
+ `serial.log: ${serialLog.length} bytes captured — not inlined (may contain provisioning credentials); see the full report`,
696
+ );
697
+ }
698
+ if (qemuLog) lines.push(`qemu.log tail:\n${qemuLog.slice(-1200)}`);
699
+ if (reportPath) lines.push(`Full report: ${reportPath}`);
700
+
701
+ return {
702
+ summary: lines.join("\n"),
703
+ reportPath,
704
+ preserved: preserveOnFailure(),
705
+ };
706
+ }
707
+
708
+ /** Per-phase timings for a QEMU monitor round-trip. All values are ms offsets
709
+ * from the start of monitorCommand(); null means the phase was never reached.
710
+ * Distinguishes issue #80's hypotheses: a null `promptAtMs` means the monitor
711
+ * never greeted us, a null `responseFirstByteAtMs` with a non-null
712
+ * `commandWrittenAtMs` means QEMU took the command and went quiet. */
713
+ export interface MonitorPhaseTimings {
714
+ connectedAtMs: number | null;
715
+ firstByteAtMs: number | null;
716
+ promptAtMs: number | null;
717
+ commandWrittenAtMs: number | null;
718
+ responseFirstByteAtMs: number | null;
719
+ bytesReceived: number;
720
+ }
721
+
722
+ export function newMonitorPhaseTimings(): MonitorPhaseTimings {
723
+ return {
724
+ connectedAtMs: null,
725
+ firstByteAtMs: null,
726
+ promptAtMs: null,
727
+ commandWrittenAtMs: null,
728
+ responseFirstByteAtMs: null,
729
+ bytesReceived: 0,
730
+ };
731
+ }
732
+
733
+ export function summarizeMonitorPhases(t: MonitorPhaseTimings): string {
734
+ const ms = (v: number | null) => (v === null ? "never" : `${v}ms`);
735
+ return (
736
+ `connect=${ms(t.connectedAtMs)} first-byte=${ms(t.firstByteAtMs)} ` +
737
+ `prompt=${ms(t.promptAtMs)} command-written=${ms(t.commandWrittenAtMs)} ` +
738
+ `response-first-byte=${ms(t.responseFirstByteAtMs)} bytes=${t.bytesReceived}`
739
+ );
740
+ }