@tikoci/quickchr 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ import type {
26
26
  } from "./types.ts";
27
27
  import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
28
28
  import packageJson from "../../package.json";
29
- import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
29
+ import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
30
30
  import {
31
31
  resolveVersion,
32
32
  isValidVersion,
@@ -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,
@@ -1526,6 +1730,8 @@ export class QuickCHR {
1526
1730
  // Build QEMU args and spawn
1527
1731
  const platform = await detectPlatform();
1528
1732
  const accel = await detectAccel(arch);
1733
+ const note = accelNote(arch, accel);
1734
+ if (note) logger.warn(note);
1529
1735
  registerSocketMembers(state);
1530
1736
  const hostfwd = buildHostfwdString(state.ports);
1531
1737
  const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
@@ -1567,7 +1773,8 @@ export class QuickCHR {
1567
1773
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1568
1774
 
1569
1775
  // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1570
- let booted = await instance.waitForBoot(bootTimeout);
1776
+ const probe = newBootProbeStats();
1777
+ let booted = await instance.waitForBoot(bootTimeout, probe);
1571
1778
 
1572
1779
  // Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
1573
1780
  // runners) occasionally produce a single QEMU process that boots but never
@@ -1584,22 +1791,35 @@ export class QuickCHR {
1584
1791
  ({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
1585
1792
  state.pid = pid;
1586
1793
  saveMachine(state);
1587
- booted = await instance.waitForBoot(bootTimeout);
1794
+ booted = await instance.waitForBoot(bootTimeout, probe);
1588
1795
  }
1589
1796
 
1590
1797
  if (!booted) {
1591
- let qemuLogTail = "";
1592
- try {
1593
- const logPath = join(machineDir, "qemu.log");
1594
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1595
- } catch { /* ignore */ }
1596
- try { await instance.stop(); } catch { /* ignore */ }
1597
- 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
+ });
1598
1817
  throw new QuickCHRError(
1599
1818
  "BOOT_TIMEOUT",
1600
1819
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1601
1820
  (hasProvisioning ? " — provisioning could not run." : ".") +
1602
- ` Machine "${name}" has been cleaned up automatically.${qemuLogTail}`,
1821
+ await cleanupAfterBootFailure(instance, name, failure) +
1822
+ `\n${failure.summary}`,
1603
1823
  );
1604
1824
  }
1605
1825
 
@@ -1838,6 +2058,8 @@ export class QuickCHR {
1838
2058
 
1839
2059
  const platform = await detectPlatform();
1840
2060
  const accel = await detectAccel(state.arch);
2061
+ const note = accelNote(state.arch, accel);
2062
+ if (note) (logger ?? createLogger()).warn(note);
1841
2063
  registerSocketMembers(state);
1842
2064
  const hostfwd = buildHostfwdString(state.ports);
1843
2065
  const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
@@ -1878,20 +2100,34 @@ export class QuickCHR {
1878
2100
 
1879
2101
  // Always wait for boot in background mode — start() promises "REST-ready".
1880
2102
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
1881
- const booted = await instance.waitForBoot(bootTimeout);
2103
+ const probe = newBootProbeStats();
2104
+ const booted = await instance.waitForBoot(bootTimeout, probe);
1882
2105
  if (!booted) {
1883
- let qemuLogTail = "";
1884
- try {
1885
- const logPath = join(state.machineDir, "qemu.log");
1886
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1887
- } catch { /* ignore */ }
1888
- try { await instance.stop(); } catch { /* ignore */ }
1889
- 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
+ });
1890
2125
  throw new QuickCHRError(
1891
2126
  "BOOT_TIMEOUT",
1892
2127
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1893
2128
  (hasProvisioning ? " — provisioning could not run." : ".") +
1894
- ` Machine "${state.name}" has been cleaned up automatically.${qemuLogTail}`,
2129
+ await cleanupAfterBootFailure(instance, state.name, failure) +
2130
+ `\n${failure.summary}`,
1895
2131
  );
1896
2132
  }
1897
2133
 
@@ -2001,10 +2237,14 @@ export class QuickCHR {
2001
2237
  try {
2002
2238
  const platform = await detectPlatform();
2003
2239
  if (platform.accelAvailable.length > 0) {
2240
+ // Under an override, accelAvailable is just the forced mode for every
2241
+ // arch — say so, or the row reads as a capability report it isn't.
2242
+ const { mode: forced, source } = resolveAccelOverrideWithSource();
2243
+ const suffix = forced === "auto" ? "" : ` — configured override via ${accelSourceLabel(source)}`;
2004
2244
  checks.push({
2005
2245
  label: "Acceleration",
2006
2246
  status: "ok",
2007
- detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})`,
2247
+ detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})${suffix}`,
2008
2248
  });
2009
2249
  } else {
2010
2250
  checks.push({
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `quickchr.env` file tier of the settings system — path, raw read, and
3
+ * dotenv-style parse.
4
+ *
5
+ * Split out of settings.ts as a **leaf** module (node:fs + paths.ts only) so
6
+ * platform.ts can resolve the `accel` override's file tier without pulling in
7
+ * settings.ts's transitive graph (settings → cache → state → network →
8
+ * platform). That cycle loads eagerly at CLI startup and measurably slows every
9
+ * `quickchr` invocation. settings.ts re-exports everything here, so this split
10
+ * is invisible to callers.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { quickchrConfigDir } from "./paths.ts";
16
+
17
+ export const SETTINGS_FILE_NAME = "quickchr.env";
18
+
19
+ /** ~/.config/quickchr/quickchr.env */
20
+ export function settingsFilePath(): string {
21
+ return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
22
+ }
23
+
24
+ /** Raw non-empty lines, in file order. [] if the file doesn't exist. */
25
+ export function readSettingsFileLines(): string[] {
26
+ const path = settingsFilePath();
27
+ if (!existsSync(path)) return [];
28
+ const text = readFileSync(path, "utf-8");
29
+ const lines = text.split("\n");
30
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
31
+ return lines;
32
+ }
33
+
34
+ /** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
35
+ export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
36
+ const result: Record<string, string> = {};
37
+ for (const rawLine of lines) {
38
+ const line = rawLine.trim();
39
+ if (line.length === 0 || line.startsWith("#")) continue;
40
+ const eq = line.indexOf("=");
41
+ if (eq <= 0) continue;
42
+ const key = line.slice(0, eq).trim();
43
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
44
+ result[key] = line.slice(eq + 1).trim();
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /** The `file` tier every resolver call reads. {} when the file is absent. */
50
+ export function loadSettingsFileDefaults(): Record<string, string> {
51
+ return parseSettingsFileLines(readSettingsFileLines());
52
+ }
@@ -13,12 +13,16 @@
13
13
  * input resolution, never per-machine config.
14
14
  */
15
15
 
16
- import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
16
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync, fsyncSync } from "node:fs";
17
17
  import { basename, dirname, join } from "node:path";
18
- import { ARCHES, CHANNELS, QuickCHRError } from "./types.ts";
19
- import type { Channel } from "./types.ts";
18
+ import { ARCHES, CHANNELS, QuickCHRError, parseAccelMode } from "./types.ts";
19
+ import type { AccelMode, Channel } from "./types.ts";
20
20
  import { parseSizeString, DEFAULT_CACHE_MAX_BYTES } from "./cache.ts";
21
- import { quickchrConfigDir } from "./paths.ts";
21
+ import { loadSettingsFileDefaults, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
22
+
23
+ // The quickchr.env file tier lives in the leaf module settings-file.ts (see its
24
+ // header for why); re-exported here so settings.ts stays the single public entry.
25
+ export { SETTINGS_FILE_NAME, loadSettingsFileDefaults, parseSettingsFileLines, readSettingsFileLines, settingsFilePath } from "./settings-file.ts";
22
26
 
23
27
  export type SettingsValue = string | number | boolean;
24
28
  export type SettingsSource = "env" | "file" | "default";
@@ -93,6 +97,17 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
93
97
  parse: parseArchSetting,
94
98
  serialize: (v) => String(v),
95
99
  },
100
+ {
101
+ // "auto" = let detectAccel() choose, including its platform fallbacks
102
+ // (notably arm64-on-Apple-Silicon → TCG, see DESIGN.md #10). Any other
103
+ // value is passed to QEMU verbatim and skips detection entirely — the
104
+ // escape hatch for testing HVF against a future AArch64-only arm64 image.
105
+ attr: "accel",
106
+ envKey: "QUICKCHR_ACCEL",
107
+ builtinDefault: "auto" satisfies AccelMode,
108
+ parse: (raw) => parseAccelMode(raw),
109
+ serialize: (v) => String(v),
110
+ },
96
111
  {
97
112
  attr: "cache-max-size",
98
113
  envKey: "QUICKCHR_CACHE_MAX_SIZE",
@@ -108,7 +123,7 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
108
123
  serialize: (v) => String(v),
109
124
  },
110
125
  {
111
- // No builtinDefault: unlike the other 4 keys, the wizard needs to distinguish
126
+ // No builtinDefault: unlike the other 5 keys, the wizard needs to distinguish
112
127
  // "not configured" from "explicitly false" (src/cli/wizard.ts's userChoice
113
128
  // initialValue only pre-highlights "admin" when the setting is concretely
114
129
  // false) — a builtinDefault here would collapse that distinction and always
@@ -123,13 +138,6 @@ export const SETTINGS_KEYS: readonly SettingsKeyDef[] = [
123
138
  },
124
139
  ];
125
140
 
126
- export const SETTINGS_FILE_NAME = "quickchr.env";
127
-
128
- /** ~/.config/quickchr/quickchr.env */
129
- export function settingsFilePath(): string {
130
- return join(quickchrConfigDir(), SETTINGS_FILE_NAME);
131
- }
132
-
133
141
  /** Accepts "default-channel" | "QUICKCHR_DEFAULT_CHANNEL" | "DEFAULT_CHANNEL" | "default_channel". */
134
142
  export function lookupSettingsKey(input: string): SettingsKeyDef | undefined {
135
143
  let name = input.trim();
@@ -150,36 +158,6 @@ function unknownKeyError(input: string): QuickCHRError {
150
158
 
151
159
  // --- File IO ---
152
160
 
153
- /** Raw non-empty lines, in file order. [] if the file doesn't exist. */
154
- export function readSettingsFileLines(): string[] {
155
- const path = settingsFilePath();
156
- if (!existsSync(path)) return [];
157
- const text = readFileSync(path, "utf-8");
158
- const lines = text.split("\n");
159
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
160
- return lines;
161
- }
162
-
163
- /** Parse QUICKCHR_*=value lines into a flat map. Blank/comment lines ignored; later dup wins. */
164
- export function parseSettingsFileLines(lines: readonly string[]): Record<string, string> {
165
- const result: Record<string, string> = {};
166
- for (const rawLine of lines) {
167
- const line = rawLine.trim();
168
- if (line.length === 0 || line.startsWith("#")) continue;
169
- const eq = line.indexOf("=");
170
- if (eq <= 0) continue;
171
- const key = line.slice(0, eq).trim();
172
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
173
- result[key] = line.slice(eq + 1).trim();
174
- }
175
- return result;
176
- }
177
-
178
- /** The `file` tier every resolver call reads. {} when the file is absent. */
179
- export function loadSettingsFileDefaults(): Record<string, string> {
180
- return parseSettingsFileLines(readSettingsFileLines());
181
- }
182
-
183
161
  function lineEnvKey(line: string): string | undefined {
184
162
  const trimmed = line.trim();
185
163
  if (trimmed.length === 0 || trimmed.startsWith("#")) return undefined;
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
 
@@ -13,6 +14,28 @@ export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term"
13
14
  export type Arch = "arm64" | "x86";
14
15
  export const ARCHES: Arch[] = ["arm64", "x86"];
15
16
 
17
+ // --- Acceleration ---
18
+
19
+ /**
20
+ * QEMU accelerator selection. "auto" means detectAccel() picks, including its
21
+ * platform safety fallbacks; the other three force `-accel <mode>` verbatim and
22
+ * bypass detection entirely (see DESIGN.md #10).
23
+ */
24
+ export type AccelMode = "auto" | "tcg" | "hvf" | "kvm";
25
+ export const ACCEL_MODES: readonly AccelMode[] = Object.freeze(["auto", "tcg", "hvf", "kvm"] as AccelMode[]);
26
+
27
+ /** Validate an accelerator name from a CLI flag, env var, or settings file. */
28
+ export function parseAccelMode(raw: string): AccelMode {
29
+ const v = raw.trim().toLowerCase();
30
+ if (!(ACCEL_MODES as readonly string[]).includes(v)) {
31
+ throw new QuickCHRError(
32
+ "INVALID_SETTING_VALUE",
33
+ `Invalid value "${raw}" for accel — must be one of ${ACCEL_MODES.join(", ")}`,
34
+ );
35
+ }
36
+ return v as AccelMode;
37
+ }
38
+
16
39
  // --- Networking ---
17
40
 
18
41
  /** @deprecated Use NetworkSpecifier instead. Kept for backward compatibility with old machine.json files. */
@@ -576,7 +599,10 @@ export interface ChrInstance {
576
599
  * After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
577
600
  * the returned instance is already REST-reachable — this returns immediately.
578
601
  * Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
579
- 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>;
580
606
  /** Poll a condition until it returns true or the timeout elapses.
581
607
  * Retries every 2 s, swallowing errors from the condition function.
582
608
  * Returns `true` when the condition passes, `false` on timeout.
@@ -762,6 +788,16 @@ export type ErrorCode =
762
788
  | "QGA_UNSUPPORTED"
763
789
  | "QGA_TIMEOUT"
764
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"
765
801
  | "MACHINE_EXISTS"
766
802
  | "MACHINE_NOT_FOUND"
767
803
  | "MACHINE_RUNNING"