@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.
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readdirSync, statSync } from "node:fs";
6
- import type { EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
7
- import { QuickCHRError as QError } from "./types.ts";
6
+ import type { AccelMode, EfiFirmwarePaths, HostInterface, PackageManager, PlatformInfo, SocketVmnetInfo } from "./types.ts";
7
+ import { QuickCHRError as QError, parseAccelMode } from "./types.ts";
8
+ // settings-file.ts, not settings.ts: the latter's graph (cache → state → network
9
+ // → platform) is a cycle that would load eagerly on every CLI start.
10
+ import { loadSettingsFileDefaults } from "./settings-file.ts";
8
11
 
9
12
  /** UEFI firmware search paths by platform. */
10
13
  const EFI_CODE_PATHS = [
@@ -111,14 +114,11 @@ export function qgaKvmWarning(): string | null {
111
114
  );
112
115
  }
113
116
 
114
- /** Whether guestArch requires cross-architecture emulation (TCG) on this host.
115
- * Cross-arch emulation is significantly slower and needs more memory/timeout.
116
- * arm64 guest on x86_64 host → TCG (slow).
117
- * x86 guest on any host → HVF or KVM when available (fast). */
117
+ /** Whether guestArch differs from the physical host CPU architecture.
118
+ * Cross-arch TCG is significantly slower and needs more memory/timeout. */
118
119
  export function isCrossArchEmulation(guestArch: "x86" | "arm64"): boolean {
119
- // x86 CHR is always runnable under HVF/KVM or TCG — never "slow" cross-arch.
120
- // arm64 CHR on an arm64 host uses HVF/KVM; on an x86_64 host it falls back to TCG.
121
- return guestArch === "arm64" && process.arch !== "arm64";
120
+ const hostIsArm64 = isAppleSiliconHost() || (process.platform !== "darwin" && process.arch === "arm64");
121
+ return (guestArch === "arm64") !== hostIsArm64;
122
122
  }
123
123
 
124
124
  /**
@@ -138,8 +138,132 @@ export function accelTimeoutFactor(accel: string, crossArch: boolean): number {
138
138
  return crossArch ? 15.0 : 4.0;
139
139
  }
140
140
 
141
+ /**
142
+ * Whether the physical host is Apple Silicon, including when quickchr itself
143
+ * runs as an x64 process under Rosetta.
144
+ *
145
+ * Apple Silicon implements **no AArch32 at any exception level**: its
146
+ * `ID_AA64PFR0_EL1` reports AArch64-only for EL0 and EL1, and HVF passes that
147
+ * hardware register straight through to the guest (the `-cpu` model is inert
148
+ * under HVF). Current arm64 CHR images ship an AArch64 kernel over a *32-bit
149
+ * ARM* userspace — the appended initramfs `/init` is `ELF 32-bit LSB ARM
150
+ * EABI5`, and the system package holds 101 further ARM32 executables — so the
151
+ * guest kernel never sets `ARM64_HAS_32BIT_EL0`, `execve("/init")` returns
152
+ * -ENOEXEC, and Linux panics ~0.08 s in with "No working init found".
153
+ * TCG's emulated CPU models do provide AArch32 EL0, so the same image boots.
154
+ *
155
+ * This is an image-artifact incompatibility, not an accelerator or CPU-model
156
+ * bug: no QEMU flag and no macOS VMM can present a feature the silicon does not
157
+ * implement. See docs/m4-hvf-arm64-investigation.md and tikoci/quickchr#97.
158
+ */
159
+ let appleSiliconHostMemo: boolean | undefined;
160
+
161
+ export function isAppleSiliconHost(): boolean {
162
+ if (process.platform !== "darwin") return false;
163
+ if (process.arch === "arm64") return true;
164
+ if (process.arch !== "x64") return false;
165
+ if (appleSiliconHostMemo !== undefined) return appleSiliconHostMemo;
166
+ try {
167
+ const result = Bun.spawnSync(["sysctl", "-n", "sysctl.proc_translated"], {
168
+ stdout: "pipe",
169
+ stderr: "pipe",
170
+ });
171
+ appleSiliconHostMemo =
172
+ result.exitCode === 0 &&
173
+ new TextDecoder().decode(result.stdout).trim() === "1";
174
+ } catch {
175
+ appleSiliconHostMemo = false;
176
+ }
177
+ return appleSiliconHostMemo;
178
+ }
179
+
180
+ /** @internal Test-only: clear the memoized Rosetta probe between mocked runs. */
181
+ export function resetAppleSiliconHostCache(): void {
182
+ appleSiliconHostMemo = undefined;
183
+ }
184
+
185
+ /**
186
+ * Process-level accelerator override, set by `--accel` (CLI) or by a library
187
+ * caller. Highest tier of the documented precedence chain
188
+ * (flag > QUICKCHR_ACCEL env > quickchr.env > built-in "auto"); `undefined`
189
+ * means "not set, fall through to the settings tiers".
190
+ */
191
+ let accelOverride: AccelMode | undefined;
192
+
193
+ /** Force the accelerator for the rest of this process, or clear with undefined. */
194
+ export function setAccelOverride(mode: AccelMode | undefined): void {
195
+ accelOverride = mode;
196
+ }
197
+
198
+ /**
199
+ * Resolve the accelerator override: process override > QUICKCHR_ACCEL env >
200
+ * quickchr.env > "auto". Mirrors settings.ts's `accel` key resolution (same
201
+ * tiers, same validator) but reads the file tier directly to stay out of that
202
+ * module's import cycle. Throws INVALID_SETTING_VALUE on an unrecognized mode.
203
+ */
204
+ export function resolveAccelOverride(): AccelMode {
205
+ return resolveAccelOverrideWithSource().mode;
206
+ }
207
+
208
+ /** Which tier supplied the accelerator, for messages that must say *why*. */
209
+ export type AccelOverrideSource = "flag" | "env" | "file" | "default";
210
+
211
+ /**
212
+ * As resolveAccelOverride(), but also reports the tier the value came from.
213
+ * With three override tiers, "TCG because ~/.config/quickchr/quickchr.env says
214
+ * so" and "TCG because you passed --accel" are very different support answers,
215
+ * so the launch note names the source rather than guessing at it.
216
+ */
217
+ export function resolveAccelOverrideWithSource(): { mode: AccelMode; source: AccelOverrideSource } {
218
+ if (accelOverride !== undefined) return { mode: accelOverride, source: "flag" };
219
+ const envRaw = process.env.QUICKCHR_ACCEL;
220
+ if (envRaw !== undefined) return { mode: parseAccelMode(envRaw), source: "env" };
221
+ const fileRaw = loadSettingsFileDefaults().QUICKCHR_ACCEL;
222
+ if (fileRaw !== undefined) return { mode: parseAccelMode(fileRaw), source: "file" };
223
+ return { mode: "auto", source: "default" };
224
+ }
225
+
226
+ /**
227
+ * User-facing name of the override tier — the thing the user would actually go
228
+ * edit. Shared by the launch note and `doctor` so both name the same source;
229
+ * never print the raw AccelOverrideSource enum, which is internal.
230
+ */
231
+ export function accelSourceLabel(source: AccelOverrideSource): string {
232
+ if (source === "flag") return "--accel";
233
+ if (source === "env") return "QUICKCHR_ACCEL";
234
+ if (source === "file") return "quickchr.env";
235
+ return "auto-detection";
236
+ }
237
+
238
+ /**
239
+ * One-line note about how the accelerator was chosen, or null when there is
240
+ * nothing worth saying. Surfaced at launch so a user on slow emulation — or
241
+ * one who forced a mode that is known not to boot — understands why.
242
+ */
243
+ export function accelNote(guestArch: "x86" | "arm64", accel: string): string | null {
244
+ const { mode: forced, source } = resolveAccelOverrideWithSource();
245
+ if (forced !== "auto") {
246
+ const base = `Accelerator configured as "${forced}" via ${accelSourceLabel(source)} — auto-detection bypassed.`;
247
+ if (guestArch === "arm64" && forced === "hvf" && isAppleSiliconHost()) {
248
+ return `${base} Note: arm64 CHR images still ship a 32-bit ARM userspace, which Apple Silicon cannot execute under HVF — expect "No working init found" unless MikroTik has shipped an AArch64-only image (tikoci/quickchr#97).`;
249
+ }
250
+ return base;
251
+ }
252
+ if (guestArch !== "arm64" || accel !== "tcg" || !isAppleSiliconHost()) return null;
253
+ return (
254
+ "arm64 CHR is running under TCG on Apple Silicon: the image's appended " +
255
+ "initramfs /init and most of its system package are 32-bit ARM, and Apple " +
256
+ "CPUs implement no AArch32 at any exception level, so an HVF guest panics " +
257
+ 'with "No working init found". TCG is slower but boots. Override with ' +
258
+ "--accel hvf once MikroTik ships an AArch64-only arm64 image (tikoci/quickchr#97)."
259
+ );
260
+ }
261
+
141
262
  /** Detect available QEMU acceleration for a guest architecture. */
142
263
  export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
264
+ const forced = resolveAccelOverride();
265
+ if (forced !== "auto") return forced;
266
+
143
267
  const hostOs = process.platform;
144
268
  const hostArch = process.arch; // "x64" | "arm64"
145
269
 
@@ -157,9 +281,10 @@ export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
157
281
  }
158
282
 
159
283
  if (hostOs === "darwin") {
160
- // Apple Hypervisor Framework (HVF) is a system capability, not a per-process one.
161
- // It is available if kern.hv_support=1, regardless of whether the bun process
162
- // itself is arm64 native or running via Rosetta (process.arch="x64").
284
+ // Apple Hypervisor Framework only runs guests matching the physical host
285
+ // architecture. Rosetta can translate the quickchr process, but it cannot
286
+ // make x86 HVF guests run on Apple Silicon.
287
+ const appleSilicon = isAppleSiliconHost();
163
288
  try {
164
289
  const hvResult = Bun.spawnSync(["sysctl", "-n", "kern.hv_support"], {
165
290
  stdout: "pipe",
@@ -167,13 +292,22 @@ export async function detectAccel(guestArch: "x86" | "arm64"): Promise<string> {
167
292
  });
168
293
  const hv = new TextDecoder().decode(hvResult.stdout).trim();
169
294
  if (hv === "1") {
170
- if (guestArch === "x86") return "hvf";
171
- // arm64 guest HVF requires a native arm64 host process.
172
- // Use process.arch — on Intel Macs this is "x64" so arm64 guests get TCG,
173
- // which is correct (Intel can't run arm64 HVF). On Apple Silicon, native
174
- // bun reports "arm64" and gets HVF. Rosetta bun reports "x64" and gets TCG
175
- // (acceptable — arm64 TCG on Apple Silicon still boots in <5 min).
176
- if (process.arch === "arm64") return "hvf";
295
+ if (guestArch === "x86" && !appleSilicon) return "hvf";
296
+ // arm64 guest: falls through to TCG on every macOS host.
297
+ //
298
+ // On Intel Macs process.arch is "x64" and arm64 HVF is impossible.
299
+ // On Apple Silicon HVF *is* available but must not be used: no Apple
300
+ // CPU implements AArch32 at any exception level, and current arm64 CHR
301
+ // images require a 32-bit ARM userspace, so an HVF guest panics at init
302
+ // (see isAppleSiliconHost()). arm64 TCG still boots, but is slower.
303
+ //
304
+ // Restore signal (#97) is the *guest artifact*, not the host or QEMU
305
+ // version: a future arm64 CHR whose appended /init and system-package
306
+ // executables are all AArch64, verified by a real HVF boot. Until then
307
+ // `--accel hvf` / QUICKCHR_ACCEL=hvf is the escape hatch for testing.
308
+ //
309
+ // x86 guests also fall through on Apple Silicon because HVF cannot
310
+ // virtualize across architectures; their escape hatch is the same.
177
311
  }
178
312
  } catch { /* fall through */ }
179
313
  }
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
  }