@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/src/lib/qemu.ts CHANGED
@@ -13,6 +13,7 @@ import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./pla
13
13
  import { buildHostfwdString } from "./network.ts";
14
14
  import { ensureDir } from "./state.ts";
15
15
  import { restGet } from "./rest.ts";
16
+ import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
16
17
 
17
18
  export interface QemuLaunchConfig {
18
19
  arch: Arch;
@@ -221,12 +222,20 @@ function buildChannelArgs(
221
222
  qgaSock = `path=${join(machineDir, "qga.sock")}`;
222
223
  }
223
224
 
225
+ // QUICKCHR_SERIAL_LOG=1 tees the serial console to <machineDir>/serial.log via
226
+ // the QEMU chardev `logfile=` option. Opt-in, never default: serial-console
227
+ // provisioning types the generated user password in cleartext (provision.ts
228
+ // provisionViaConsole), so this log is secret-bearing. It is the only record of
229
+ // what RouterOS printed when a boot never reaches REST — the socket chardev
230
+ // keeps no history, so connecting after the failure shows nothing (#79).
231
+ const serialLog = serialLogEnabled() ? `,logfile=${join(machineDir, "serial.log")},logappend=on` : "";
232
+
224
233
  if (background) {
225
234
  // Background: all channels on sockets/pipes
226
235
  args.push(
227
236
  "-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
228
237
  "-mon", "chardev=monitor0,mode=readline",
229
- "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
238
+ "-chardev", `socket,id=serial0,${serialSock},server=on,wait=off${serialLog}`,
230
239
  "-serial", "chardev:serial0",
231
240
  );
232
241
  } else {
@@ -517,9 +526,11 @@ export async function waitForBoot(
517
526
  httpPort: number,
518
527
  timeoutMs: number = 120_000,
519
528
  auth: string = `Basic ${btoa("admin:")}`,
529
+ stats?: BootProbeStats,
520
530
  ): Promise<boolean> {
521
531
  const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
522
- const deadline = Date.now() + timeoutMs;
532
+ const start = Date.now();
533
+ const deadline = start + timeoutMs;
523
534
  let consecutiveReady = 0;
524
535
 
525
536
  while (Date.now() < deadline) {
@@ -530,6 +541,7 @@ export async function waitForBoot(
530
541
  // Auth rejected — the REST layer is up and responded, even though
531
542
  // we cannot validate the body. Count as ready.
532
543
  consecutiveReady++;
544
+ recordBootProbe(stats, Date.now() - start, "unauthorized", String(status));
533
545
  } else if (status >= 200 && status < 300) {
534
546
  // RouterOS REST may return wrong data (array body) briefly after boot —
535
547
  // a startup race. Only accept once the body is the expected singleton object.
@@ -537,17 +549,24 @@ export async function waitForBoot(
537
549
  try { parsed = JSON.parse(body); } catch { /* ignore */ }
538
550
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
539
551
  consecutiveReady++;
552
+ recordBootProbe(stats, Date.now() - start, "ready");
540
553
  } else {
541
554
  consecutiveReady = 0; // wrong body — REST not fully initialized yet
555
+ recordBootProbe(stats, Date.now() - start, "wrong-body", body.slice(0, 80));
542
556
  }
543
557
  } else {
544
558
  consecutiveReady = 0;
559
+ recordBootProbe(stats, Date.now() - start, "http-error", String(status));
545
560
  }
546
561
 
547
562
  if (consecutiveReady >= 2) return true;
548
- } catch {
549
- // ECONNRESET, connection refused, timeout — not ready yet
563
+ } catch (e) {
564
+ // ECONNRESET, connection refused, timeout — not ready yet. Which one it
565
+ // was is the whole diagnosis when a boot never completes (#79), so it is
566
+ // classified rather than swallowed.
550
567
  consecutiveReady = 0;
568
+ const { outcome, detail } = classifyProbeError(e);
569
+ recordBootProbe(stats, Date.now() - start, outcome, detail);
551
570
  }
552
571
  await Bun.sleep(2000);
553
572
  }
@@ -56,7 +56,8 @@ import { autoPruneIfOverCap } from "./cache.ts";
56
56
  import { resolveSetting } from "./settings.ts";
57
57
  import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
58
58
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
59
- import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
59
+ import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
60
+ import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
60
61
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
61
62
  import { provision } from "./provision.ts";
62
63
  import { renewLicense, getLicenseInfo } from "./license.ts";
@@ -65,7 +66,7 @@ import { scpPush, scpPull } from "./scp.ts";
65
66
  import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
66
67
  import { restExecute } from "./exec.ts";
67
68
  import { qgaExec } from "./qga.ts";
68
- import { consoleExec } from "./console.ts";
69
+ import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
69
70
  import { restRequest, restGet, restPost } from "./rest.ts";
70
71
  import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
71
72
  import { createLogger, type ProgressLogger } from "./log.ts";
@@ -79,6 +80,7 @@ import {
79
80
  waitForDeviceModeApi,
80
81
  } from "./device-mode.ts";
81
82
  import type { LicenseOptions } from "./types.ts";
83
+ import type { GuestExec } from "./guest-snapshot.ts";
82
84
  import { toChrPorts } from "./network.ts";
83
85
  import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
84
86
  import { existsSync, readdirSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
@@ -412,11 +414,11 @@ function createInstance(state: MachineState): ChrInstance {
412
414
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
413
415
  tzspGatewayIp: "10.0.2.2",
414
416
 
415
- async waitForBoot(timeoutMs?: number): Promise<boolean> {
417
+ async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
416
418
  // Use resolved credentials so waitForBoot can validate the response body
417
419
  // on authenticated machines (post-provisioning, post-install reboots).
418
420
  const auth = resolveAuth(state);
419
- return waitForBoot(ports.http, timeoutMs, auth.header);
421
+ return waitForBoot(ports.http, timeoutMs, auth.header, stats);
420
422
  },
421
423
 
422
424
  async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
@@ -487,18 +489,43 @@ function createInstance(state: MachineState): ChrInstance {
487
489
  state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
488
490
  );
489
491
 
490
- // Clean up stored instance credentials (re-provisioned on next start)
492
+ // Every guest-side credential went with the old disk, so the credential
493
+ // facts in machine.json now describe a machine that no longer exists.
494
+ // Nothing re-provisions them either: a post-clean() start() takes the
495
+ // `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
496
+ // erased account is never recreated. Left in place, `resolveAuth()` /
497
+ // `resolveCreds()` prefer `state.user` and authenticate every REST call,
498
+ // exec, and SCP as a deleted user — the confound #79 removes here.
499
+ // Clearing them restores the documented fallback: factory `admin` with an
500
+ // empty password, which is what a fresh CHR image actually answers to
501
+ // (anchored by the "clean() resets disk to factory defaults" integration
502
+ // test). `disableAdmin` goes with them because it is read as a live fact
503
+ // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
504
+ // image has admin enabled again. Provisioning *intent* — packages,
505
+ // deviceMode, secureLogin — is not guest state and survives.
491
506
  deleteInstanceCredentials(state.name);
507
+ // The managed keypair authenticated to the account factory reset erased,
508
+ // so it is dead credential material sitting in the machine dir. Removing
509
+ // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
510
+ // existing file exits 1 with "already exists" rather than overwriting
511
+ // (verified locally), so a leftover key would break `installSshKey()`.
512
+ rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
492
513
 
493
514
  // Update state
494
515
  const current = loadMachine(state.name);
495
516
  if (current) {
496
517
  current.status = "stopped";
497
518
  current.pid = undefined;
519
+ current.user = undefined;
520
+ current.managedSshKey = undefined;
521
+ current.disableAdmin = undefined;
498
522
  saveMachine(current);
499
523
  }
500
524
  state.status = "stopped";
501
525
  state.pid = undefined;
526
+ state.user = undefined;
527
+ state.managedSshKey = undefined;
528
+ state.disableAdmin = undefined;
502
529
  },
503
530
 
504
531
  async monitor(command: string): Promise<string> {
@@ -950,6 +977,183 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
950
977
  return false;
951
978
  }
952
979
 
980
+ /** Where boot-failure reports land: `<dataDir>/failures/`, deliberately outside
981
+ * any machine directory so `remove()` — which every integration test calls from
982
+ * its `finally` — cannot delete the evidence it was written to explain (#79). */
983
+ function bootFailureReportDir(): string {
984
+ return join(getDataDir(), "failures");
985
+ }
986
+
987
+ /**
988
+ * How long one credential candidate gets inside a single guest-exec call.
989
+ *
990
+ * `timeoutMs` is a budget for the whole call, not per attempt: `captureGuestSnapshot()`
991
+ * sizes its own budget assuming one exec costs at most what it passed in, so N
992
+ * candidates × the full timeout would spend the snapshot's entire budget on the
993
+ * first query. Untried candidates therefore share it, and once a credential is
994
+ * known to work it gets all of it — which is every query after the first.
995
+ *
996
+ * The floor is {@link CONSOLE_LOGIN_COST_MS} rather than a round number, because
997
+ * a share below the cost of a login cannot succeed **by construction** — and a
998
+ * candidate that can never succeed also never becomes the working one, so every
999
+ * later query repeats the same doomed division. The floor used to be 5 s against
1000
+ * a measured 11.4 s login, which is why forensics reported "console unreachable"
1001
+ * for guests that were answering serial in 10 ms (#69). A share this large is
1002
+ * affordable only because `captureGuestSnapshot()` adds a login allowance to its
1003
+ * query budget until the console answers; see `GUEST_LOGIN_ALLOWANCE_MS`.
1004
+ *
1005
+ * Exported for tests: this is the arithmetic the #69/B10 fix turns on, and it is
1006
+ * otherwise reachable only through a live serial socket.
1007
+ */
1008
+ export function credentialShareMs(timeoutMs: number, candidateCount: number, haveWorking: boolean): number {
1009
+ if (haveWorking) return timeoutMs;
1010
+ return Math.max(CONSOLE_LOGIN_COST_MS, Math.floor(timeoutMs / Math.max(1, candidateCount)));
1011
+ }
1012
+
1013
+ /** Serial-console executor for boot forensics, or null when there is no serial
1014
+ * channel to talk to (foreground runs, or QEMU never got far enough).
1015
+ *
1016
+ * Which account works depends on how far the failed boot got: a machine that
1017
+ * timed out before provisioning — and any machine after `clean()` — is factory
1018
+ * fresh and answers only to `admin` with an empty password, while a relaunch of
1019
+ * an already-provisioned machine needs its stored credentials. Rather than
1020
+ * guess, candidates are tried in order and the first that reaches a prompt is
1021
+ * reused for the rest of the snapshot. */
1022
+ function bootFailureGuestExec(state: MachineState): { exec: GuestExec; users: string[] } | null {
1023
+ if (!channelFileExists(channelPath(state.machineDir, "serial"))) return null;
1024
+
1025
+ // Order matters only for speed — a wrong candidate costs a ~15 s login timeout
1026
+ // before the next is tried. Stored credentials are the strongest evidence the
1027
+ // machine is provisioned (provisioning stores them on success), so they lead;
1028
+ // factory admin comes next because a cleaned machine has neither (`clean()`
1029
+ // clears both the stored credentials and `state.user`), leaving `state.user`
1030
+ // as the weakest case: a user configured on the machine but never provisioned
1031
+ // onto the guest.
1032
+ const candidates: { user: string; password: string }[] = [];
1033
+ // Guarded even though secrets.ts already swallows unreadable/corrupt stores:
1034
+ // this runs on the BOOT_TIMEOUT path *before* captureBootFailure(), so
1035
+ // anything thrown here would replace the forensics report with an unrelated
1036
+ // error — the exact failure mode the diagnostics modules exist to avoid.
1037
+ let stored: { user: string; password: string } | null = null;
1038
+ try {
1039
+ stored = getInstanceCredentials(state.name);
1040
+ } catch { /* no stored credentials — fall through to the other candidates */ }
1041
+ if (stored) candidates.push(stored);
1042
+ candidates.push({ user: "admin", password: "" });
1043
+ if (state.user && state.user.name !== "admin" && state.user.password !== STORED_IN_SECRETS_PASSWORD) {
1044
+ candidates.push({ user: state.user.name, password: state.user.password });
1045
+ }
1046
+
1047
+ let working: { user: string; password: string } | undefined;
1048
+ const exec: GuestExec = async (command, timeoutMs) => {
1049
+ const order = working ? [working, ...candidates.filter((c) => c !== working)] : candidates;
1050
+ const deadline = Date.now() + timeoutMs;
1051
+ const share = credentialShareMs(timeoutMs, order.length, working !== undefined);
1052
+ let lastError: unknown = new Error("no credential candidates");
1053
+ for (const cred of order) {
1054
+ const remaining = deadline - Date.now();
1055
+ if (remaining <= 0) break;
1056
+ try {
1057
+ const { output, framed } = await consoleExec(
1058
+ state.machineDir, command, cred.user, cred.password, Math.min(share, remaining), state.portBase,
1059
+ );
1060
+ working = cred;
1061
+ return { output, framed };
1062
+ } catch (e) {
1063
+ lastError = e;
1064
+ }
1065
+ }
1066
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1067
+ };
1068
+
1069
+ return { exec, users: candidates.map((c) => c.user) };
1070
+ }
1071
+
1072
+ /**
1073
+ * Capture the boot-failure evidence set for a machine that is **already
1074
+ * running** — a request that failed after the machine was declared REST-ready.
1075
+ *
1076
+ * Same instruments as the BOOT_TIMEOUT path (guest snapshot, per-port slirp
1077
+ * classification, monitor state, QEMU liveness, embedded logs), because #69's
1078
+ * one-line `ECONNRESET` has never been enough to say whether the reset came from
1079
+ * the client, RouterOS `www`, or slirp — and every one of those instruments
1080
+ * answers a different part of that. What it does *not* share is the boot
1081
+ * budget's excuse for the machine looking healthy: {@link FailureTrigger} is
1082
+ * required here, so the report always records which request died and what
1083
+ * changed just before it.
1084
+ *
1085
+ * Does not stop, remove, or otherwise touch the machine — the caller owns its
1086
+ * lifecycle and may well want to keep testing against it. Never throws;
1087
+ * `captureBootFailure()` swallows its own failures.
1088
+ *
1089
+ * One trace it does leave, and the reason that matters more here than on the
1090
+ * boot path: the guest snapshot logs in over the serial console, and a RouterOS
1091
+ * console login survives the host socket closing. A boot-timeout capture is
1092
+ * followed by `stop()`/`remove()`, so nothing outlives it; here the machine
1093
+ * keeps running. Harmless — `consoleExec()` `/quit`s a session belonging to a
1094
+ * different user before it logs in — but a later console interaction on this
1095
+ * machine starts from an already-logged-in prompt, not a `Login:` one.
1096
+ */
1097
+ export async function captureRunningFailure(
1098
+ state: MachineState,
1099
+ phase: string,
1100
+ trigger: FailureTrigger,
1101
+ ): Promise<BootFailureReport> {
1102
+ // Approximate, and labelled as such in the field docs: lastStartedAt is
1103
+ // stamped at spawn and lastBootMs measured from just before it, so their sum
1104
+ // is REST-ready to within the few ms between those two statements. Both are
1105
+ // absent on a machine that never completed a timed boot, in which case the
1106
+ // field is simply omitted rather than guessed at.
1107
+ const readyAt = state.lastStartedAt !== undefined && state.lastBootMs !== undefined
1108
+ ? Date.parse(state.lastStartedAt) + state.lastBootMs
1109
+ : undefined;
1110
+ const guest = bootFailureGuestExec(state);
1111
+ return await captureBootFailure({
1112
+ name: state.name,
1113
+ machineDir: state.machineDir,
1114
+ arch: state.arch,
1115
+ accel: state.lastAccel ?? "unknown",
1116
+ pid: state.pid,
1117
+ httpPort: state.ports.http?.host,
1118
+ portBase: state.portBase,
1119
+ phase,
1120
+ trigger: {
1121
+ ...trigger,
1122
+ sinceReadyMs: trigger.sinceReadyMs
1123
+ ?? (readyAt !== undefined && Number.isFinite(readyAt) ? Date.now() - readyAt : undefined),
1124
+ },
1125
+ reportDir: bootFailureReportDir(),
1126
+ monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
1127
+ forwards: Object.values(state.ports),
1128
+ guestExec: guest?.exec,
1129
+ guestUser: guest?.users.join(" → "),
1130
+ });
1131
+ }
1132
+
1133
+ /** Tear down a machine that failed to boot, and describe what was done.
1134
+ *
1135
+ * Default is full cleanup — a failed `start()` must not leave a half-dead
1136
+ * machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
1137
+ * inverts that for **local** debugging: the QEMU process is still stopped (it is
1138
+ * wedged, and its port block must be released), but the machine directory
1139
+ * survives so it can be inspected by hand.
1140
+ *
1141
+ * Evidence does not depend on that flag, and CI deliberately does not set it —
1142
+ * `captureBootFailure()` has already written its report outside the machine dir
1143
+ * with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
1144
+ async function cleanupAfterBootFailure(
1145
+ instance: ChrInstance,
1146
+ name: string,
1147
+ failure: BootFailureReport,
1148
+ ): Promise<string> {
1149
+ try { await instance.stop(); } catch { /* ignore */ }
1150
+ if (failure.preserved) {
1151
+ return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
1152
+ }
1153
+ try { await instance.remove(); } catch { /* ignore */ }
1154
+ return ` Machine "${name}" has been cleaned up automatically.`;
1155
+ }
1156
+
953
1157
  /** Wait for CHR to boot with periodic progress status updates every 20s. */
954
1158
  async function waitForBootWithProgress(
955
1159
  instance: ChrInstance,
@@ -1569,7 +1773,8 @@ export class QuickCHR {
1569
1773
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1570
1774
 
1571
1775
  // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1572
- let booted = await instance.waitForBoot(bootTimeout);
1776
+ const probe = newBootProbeStats();
1777
+ let booted = await instance.waitForBoot(bootTimeout, probe);
1573
1778
 
1574
1779
  // Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
1575
1780
  // runners) occasionally produce a single QEMU process that boots but never
@@ -1586,22 +1791,35 @@ export class QuickCHR {
1586
1791
  ({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
1587
1792
  state.pid = pid;
1588
1793
  saveMachine(state);
1589
- booted = await instance.waitForBoot(bootTimeout);
1794
+ booted = await instance.waitForBoot(bootTimeout, probe);
1590
1795
  }
1591
1796
 
1592
1797
  if (!booted) {
1593
- let qemuLogTail = "";
1594
- try {
1595
- const logPath = join(machineDir, "qemu.log");
1596
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1597
- } catch { /* ignore */ }
1598
- try { await instance.stop(); } catch { /* ignore */ }
1599
- try { await instance.remove(); } catch { /* ignore */ }
1798
+ const guest = bootFailureGuestExec(state);
1799
+ const failure = await captureBootFailure({
1800
+ name,
1801
+ machineDir,
1802
+ arch,
1803
+ accel,
1804
+ bootTimeoutMs: bootTimeout,
1805
+ pid,
1806
+ httpPort: instance.ports.http,
1807
+ portBase: state.portBase,
1808
+ qemuArgs,
1809
+ probe,
1810
+ phase: "start",
1811
+ reportDir: bootFailureReportDir(),
1812
+ monitorQuery: (cmd) => monitorCommand(machineDir, cmd, 3000, state.portBase),
1813
+ forwards: Object.values(state.ports),
1814
+ guestExec: guest?.exec,
1815
+ guestUser: guest?.users.join(" → "),
1816
+ });
1600
1817
  throw new QuickCHRError(
1601
1818
  "BOOT_TIMEOUT",
1602
1819
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1603
1820
  (hasProvisioning ? " — provisioning could not run." : ".") +
1604
- ` Machine "${name}" has been cleaned up automatically.${qemuLogTail}`,
1821
+ await cleanupAfterBootFailure(instance, name, failure) +
1822
+ `\n${failure.summary}`,
1605
1823
  );
1606
1824
  }
1607
1825
 
@@ -1882,20 +2100,34 @@ export class QuickCHR {
1882
2100
 
1883
2101
  // Always wait for boot in background mode — start() promises "REST-ready".
1884
2102
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
1885
- const booted = await instance.waitForBoot(bootTimeout);
2103
+ const probe = newBootProbeStats();
2104
+ const booted = await instance.waitForBoot(bootTimeout, probe);
1886
2105
  if (!booted) {
1887
- let qemuLogTail = "";
1888
- try {
1889
- const logPath = join(state.machineDir, "qemu.log");
1890
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1891
- } catch { /* ignore */ }
1892
- try { await instance.stop(); } catch { /* ignore */ }
1893
- try { await instance.remove(); } catch { /* ignore */ }
2106
+ const guest = bootFailureGuestExec(state);
2107
+ const failure = await captureBootFailure({
2108
+ name: state.name,
2109
+ machineDir: state.machineDir,
2110
+ arch: state.arch,
2111
+ accel,
2112
+ bootTimeoutMs: bootTimeout,
2113
+ pid,
2114
+ httpPort: instance.ports.http,
2115
+ portBase: state.portBase,
2116
+ qemuArgs,
2117
+ probe,
2118
+ phase: "_launchExisting",
2119
+ reportDir: bootFailureReportDir(),
2120
+ monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
2121
+ forwards: Object.values(state.ports),
2122
+ guestExec: guest?.exec,
2123
+ guestUser: guest?.users.join(" → "),
2124
+ });
1894
2125
  throw new QuickCHRError(
1895
2126
  "BOOT_TIMEOUT",
1896
2127
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1897
2128
  (hasProvisioning ? " — provisioning could not run." : ".") +
1898
- ` Machine "${state.name}" has been cleaned up automatically.${qemuLogTail}`,
2129
+ await cleanupAfterBootFailure(instance, state.name, failure) +
2130
+ `\n${failure.summary}`,
1899
2131
  );
1900
2132
  }
1901
2133
 
package/src/lib/types.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ProgressLogger } from "./log.ts";
6
+ import type { BootProbeStats } from "./diagnostics.ts";
6
7
 
7
8
  // --- Version & Architecture ---
8
9
 
@@ -598,7 +599,10 @@ export interface ChrInstance {
598
599
  * After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
599
600
  * the returned instance is already REST-reachable — this returns immediately.
600
601
  * Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
601
- waitForBoot(timeoutMs?: number): Promise<boolean>;
602
+ /** @param stats Optional out-param; each REST readiness probe is classified
603
+ * into it (see diagnostics.ts BootProbeStats) so a failed wait can explain
604
+ * whether the forwarded port ever answered. */
605
+ waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean>;
602
606
  /** Poll a condition until it returns true or the timeout elapses.
603
607
  * Retries every 2 s, swallowing errors from the condition function.
604
608
  * Returns `true` when the condition passes, `false` on timeout.
@@ -784,6 +788,16 @@ export type ErrorCode =
784
788
  | "QGA_UNSUPPORTED"
785
789
  | "QGA_TIMEOUT"
786
790
  | "DOWNLOAD_FAILED"
791
+ // A transfer that received nothing for DOWNLOAD_STALL_MS. Retriable, and
792
+ // retried before this surfaces — a wedged socket usually moves on the next
793
+ // attempt (#116).
794
+ | "DOWNLOAD_STALLED"
795
+ // A transfer that kept moving but could not finish inside a budget derived
796
+ // from its content-length and COLD_DOWNLOAD_FLOOR_BYTES_PER_S. Terminal on
797
+ // purpose: retrying re-downloads from zero on a link already measured slower
798
+ // than the floor, which is the "one slow transfer becomes three" behavior
799
+ // #116 exists to remove.
800
+ | "DOWNLOAD_TOO_SLOW"
787
801
  | "MACHINE_EXISTS"
788
802
  | "MACHINE_NOT_FOUND"
789
803
  | "MACHINE_RUNNING"
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Arch, Channel } from "./types.ts";
6
6
  import { CHANNELS, QuickCHRError } from "./types.ts";
7
- import { fetchResilient } from "./net.ts";
7
+ import { fetchResilient, isConnectionFailure } from "./net.ts";
8
8
 
9
9
  const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
10
10
  const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
@@ -65,35 +65,112 @@ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION):
65
65
  return `Use --channel long-term or --version ${minimumVersion}+ for ${PROVISIONING_FEATURE_SUMMARY}, or keep the older version without provisioning options.`;
66
66
  }
67
67
 
68
- /** Fetch the latest version for a given channel from MikroTik's upgrade server. */
69
- export async function resolveVersion(channel: Channel): Promise<string> {
70
- const url = `${UPGRADE_BASE}.${channel}`;
68
+ /** Attempts for a connection-class failure while resolving a channel version. */
69
+ export const VERSION_RESOLVE_MAX_ATTEMPTS = 3;
70
+
71
+ /** Backoff base: attempt N waits N × this before N+1. Mirrors `downloadToFile`. */
72
+ const VERSION_RESOLVE_RETRY_MS = 2000;
73
+
74
+ export interface ResolveVersionOptions {
75
+ /** Attempts for connection-class failures. Defaults to {@link VERSION_RESOLVE_MAX_ATTEMPTS}. */
76
+ maxAttempts?: number;
77
+ /**
78
+ * Override the backoff base. A test lever, like `downloadToFile`'s `stallMs`:
79
+ * the real backoff spends 6 s of wall clock exhausting the default attempts.
80
+ * Production callers should not set it.
81
+ */
82
+ retryDelayMs?: number;
83
+ }
71
84
 
72
- const response = await fetchResilient(url);
73
- if (!response.ok) {
74
- throw new QuickCHRError(
75
- "DOWNLOAD_FAILED",
76
- `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
77
- );
78
- }
85
+ /** A whole count of at least 1, or `fallback` for anything that isn't one. */
86
+ function positiveInt(value: number | undefined, fallback: number): number {
87
+ if (value === undefined || !Number.isFinite(value)) return fallback;
88
+ return Math.max(1, Math.floor(value));
89
+ }
90
+
91
+ /** A finite, non-negative delay in ms, or `fallback` for anything that isn't one. */
92
+ function nonNegativeMs(value: number | undefined, fallback: number): number {
93
+ if (value === undefined || !Number.isFinite(value)) return fallback;
94
+ return Math.max(0, value);
95
+ }
79
96
 
80
- const text = await response.text();
81
- // Response format: "7.22.1 1774276515" (version + unix timestamp)
82
- const version = text.trim().split(/\s+/)[0]?.trim();
83
- if (!version || !isValidVersion(version)) {
84
- throw new QuickCHRError(
85
- "INVALID_VERSION",
86
- `Unexpected version format for channel "${channel}": "${text.trim()}"`,
87
- );
97
+ /**
98
+ * Fetch the latest version for a given channel from MikroTik's upgrade server.
99
+ *
100
+ * Retried on a connection-class failure only — a transient refusal at
101
+ * `upgrade.mikrotik.com` used to fail a whole `quickchr start`, since this sits
102
+ * on the critical path of `start()`, the wizard and the CLI while only the
103
+ * download path had a retry policy (#121). An HTTP answer is the server's verdict
104
+ * and stays terminal on the first attempt: a 404 for a bad channel must fail fast.
105
+ */
106
+ export async function resolveVersion(
107
+ channel: Channel,
108
+ opts: ResolveVersionOptions = {},
109
+ ): Promise<string> {
110
+ const url = `${UPGRADE_BASE}.${channel}`;
111
+ // Sanitized, not just clamped: these are public options, and `Math.max(1, NaN)`
112
+ // is NaN — the loop would never run, and the failure would read "all NaN
113
+ // attempts: undefined" instead of doing anything.
114
+ const maxAttempts = positiveInt(opts.maxAttempts, VERSION_RESOLVE_MAX_ATTEMPTS);
115
+ const retryDelayMs = nonNegativeMs(opts.retryDelayMs, VERSION_RESOLVE_RETRY_MS);
116
+ let lastError: unknown;
117
+
118
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ let text: string;
120
+ try {
121
+ const response = await fetchResilient(url);
122
+ if (!response.ok) {
123
+ // An HTTP answer is the server's verdict. Thrown as a QuickCHRError so
124
+ // the catch below can tell it from a transport failure and let it out.
125
+ throw new QuickCHRError(
126
+ "DOWNLOAD_FAILED",
127
+ `Failed to fetch version for channel "${channel}": HTTP ${response.status}`,
128
+ );
129
+ }
130
+ // Inside the try: the body can fail *after* the headers arrive (a reset
131
+ // mid-response), which is a transport failure like any other and must be
132
+ // retried rather than escaping the loop unwrapped.
133
+ text = await response.text();
134
+ } catch (err) {
135
+ // Only "could not reach the host at all" is retried. An HTTP verdict, an
136
+ // abort (the caller gave up) or a real bug surfaces immediately.
137
+ if (err instanceof QuickCHRError) throw err;
138
+ if (!isConnectionFailure(err)) throw err;
139
+ lastError = err;
140
+ if (attempt < maxAttempts) await Bun.sleep(attempt * retryDelayMs);
141
+ continue;
142
+ }
143
+
144
+ // Parsing is terminal: the server answered, and answering with something
145
+ // unparseable is not a condition another attempt can improve.
146
+ // Response format: "7.22.1 1774276515" (version + unix timestamp)
147
+ const version = text.trim().split(/\s+/)[0]?.trim();
148
+ if (!version || !isValidVersion(version)) {
149
+ throw new QuickCHRError(
150
+ "INVALID_VERSION",
151
+ `Unexpected version format for channel "${channel}": "${text.trim()}"`,
152
+ );
153
+ }
154
+
155
+ return version;
88
156
  }
89
157
 
90
- return version;
158
+ // Says "1 attempt" rather than "all 1 attempts" — these land in CI logs.
159
+ const tries = maxAttempts === 1 ? "1 attempt" : `all ${maxAttempts} attempts`;
160
+ throw new QuickCHRError(
161
+ "DOWNLOAD_FAILED",
162
+ `Failed to fetch version for channel "${channel}" on ${tries}: ` +
163
+ `${lastError instanceof Error ? lastError.message : String(lastError)}`,
164
+ `Check network access to ${new URL(url).hostname}, or pass an explicit --version to skip channel resolution.`,
165
+ );
91
166
  }
92
167
 
93
168
  /** Fetch latest versions for all channels in parallel. */
94
- export async function resolveAllVersions(): Promise<Record<Channel, string>> {
169
+ export async function resolveAllVersions(
170
+ opts: ResolveVersionOptions = {},
171
+ ): Promise<Record<Channel, string>> {
95
172
  const results = await Promise.all(
96
- CHANNELS.map(async (ch) => [ch, await resolveVersion(ch)] as const),
173
+ CHANNELS.map(async (ch) => [ch, await resolveVersion(ch, opts)] as const),
97
174
  );
98
175
  return Object.fromEntries(results) as Record<Channel, string>;
99
176
  }