@tikoci/quickchr 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -54,6 +54,14 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
54
54
 
55
55
  **Sister-project routing.** Because `fetchResilient` already absorbs a flaky resolver, a download/version-resolve failure should not be papered over downstream with `/etc/hosts` pins or IPv6 toggles in a consuming repo's workflow — fix it (or extend the failback) here in quickchr. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). The failback is best-effort, not a guarantee: it recovers connection-class DNS failures, not arbitrary network breakage. Covered by `test/unit/net.test.ts`.
56
56
 
57
+ 10. **arm64 CHR is forced onto TCG on all Apple Silicon** — `detectAccel("arm64")` never returns `hvf` on macOS; the launch path surfaces `accelNote()` explaining why (`src/lib/platform.ts`). The cause is an **image artifact**, not an accelerator or CPU-model bug: current arm64 CHR images (7.20.8 – 7.23beta5 verified) ship an AArch64 kernel over a **32-bit ARM userspace** — the appended initramfs `/init` is `ELF 32-bit LSB ARM, EABI5, static`, and the 7.22.1 `system` package holds **101 further ARM32 executables and 18 ARM32 shared objects** (the only AArch64 executables are `kexec` and `vmcore-dmesg`). Apple Silicon implements **no AArch32 at any exception level**: `ID_AA64PFR0_EL1` reports AArch64-only for EL0/EL1, and QEMU under HVF passes that hardware register straight through (`hvf_arch_init_vcpu()` re-reads the live vCPU register and only edits the GIC bit; the `-cpu` model is inert). So the guest kernel never sets `ARM64_HAS_32BIT_EL0`, `compat_elf_check_arch()` rejects the `EM_ARM` `/init` with `-ENOEXEC`, the initramfs has no fallback init, and Linux panics at t≈0.076 s with `No working init found`. TCG's emulated models do provide AArch32 EL0, so the same unmodified image boots to `MikroTik Login:`. Grounded in the guest's own panic-time capability bitmap: the M4/HVF guest printed `0x20012,28000230` (8 caps, `ARM64_HAS_32BIT_EL0` absent) against TCG's `0x20013,28402230` (11 caps, present). Full chain in [`docs/m4-hvf-arm64-investigation.md`](docs/m4-hvf-arm64-investigation.md).
58
+
59
+ **Scope is every Apple Silicon generation, not M4+.** The first report came from an M4 (tikoci/mikropkl#11) and the original fix keyed on `hw.optional.arm.FEAT_SSBS == 0`, which M4 is the first Apple chip to report — but SSBS was a **coincident marker of the reporting host, not the mechanism**. Linux 5.6 treats SSBS as an optional mitigation and boots fine without it (`cortex-a53`/`neoverse-n1` lack it), while *no* Apple CPU since 2020 implements AArch32. The SSBS predicate therefore left M1/M2/M3 on HVF and panicking — a live bug, not merely a narrow gate. Ordinary server ARM (Ampere, Graviton) does implement AArch32 EL0, which is why arm64 CHR runs there under KVM; this is an Apple-Silicon-and-CHR-artifact interaction, not a RouterOS-on-ARM defect.
60
+
61
+ **No QEMU flag or version fixes this.** Apple's `Hypervisor.framework` exposes only `hv_vcpu_config_get_feature_reg()` — there is no setter — so no macOS VMM can present a feature the silicon lacks (UTM's fork behaves identically). A QEMU version floor is therefore *not* a valid restore signal, and the deferred `getQemuVersion()` guard the SSBS-era decision contemplated has been dropped. **The restore signal is the guest artifact:** a future arm64 CHR whose appended `/init` *and* required system-package executables/shared objects are all AArch64, confirmed by a real HVF boot. That check is mechanical (§"Reproduction" in the investigation doc) and could become a release-time guard.
62
+
63
+ **Escape hatch.** Because the fallback is unconditional, `--accel <auto|tcg|hvf|kvm>` (and the `accel` setting / `QUICKCHR_ACCEL` env var) forces the accelerator verbatim and bypasses detection — so a fixed future image can be tested on HVF without a code change, and so forcing TCG stays possible for debugging elsewhere. `--accel hvf` on an arm64 guest still prints the panic caveat rather than silently obeying. This complements, and does not replace, the reactive respawn-once net in #8. Covered by `test/unit/platform.test.ts`. → [#97](https://github.com/tikoci/quickchr/issues/97)
64
+
57
65
  ## Port Layout
58
66
 
59
67
  | Offset | Service | Guest Port |
@@ -100,8 +108,9 @@ Current consumers are all first-party tikoci projects (centrs, donny, restraml),
100
108
  **Global settings** live separately, under the XDG **config** tier (not the data tree
101
109
  above): `~/.config/quickchr/quickchr.env`, dotenv-style (`QUICKCHR_KEY=value` lines).
102
110
  Managed via `quickchr settings get|set|print|reset`. Precedence per key: CLI flag >
103
- `QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 5 managed keys:
104
- `default-channel`, `default-arch`, `cache-max-size`, `timeout-extra`, `secure-login`.
111
+ `QUICKCHR_<KEY>` env var > `quickchr.env` > built-in default. The 6 managed keys:
112
+ `default-channel`, `default-arch`, `accel`, `cache-max-size`, `timeout-extra`,
113
+ `secure-login`.
105
114
  See MANUAL.md's CLI reference and environment-variables sections for the full surface.
106
115
 
107
116
  ## Platform Support
@@ -109,16 +118,20 @@ See MANUAL.md's CLI reference and environment-variables sections for the full su
109
118
  | Platform | x86 CHR | arm64 CHR | Notes |
110
119
  |------------------------|---------|-----------|-------|
111
120
  | macOS x86_64 | HVF | TCG | Intel Mac |
112
- | macOS arm64 (native) | HVF | HVF | Apple Silicon, bun is arm64 |
113
- | macOS arm64 (Rosetta) | HVF | TCG | bun is x86_64; arm64 HVF skipped |
121
+ | macOS arm64 (native) | TCG | TCG¹ | Apple Silicon, bun is arm64 |
122
+ | macOS arm64 (Rosetta) | TCG | TCG¹ | Apple Silicon, bun reports x86_64 |
114
123
  | Linux x86_64 | KVM | TCG | KVM requires `/dev/kvm` writable |
115
124
  | Linux aarch64 | TCG | KVM | x86 TCG on arm64 Linux |
116
125
  | Windows x86_64 | TCG | TCG | HVF/KVM not available |
117
126
 
127
+ ¹ arm64 CHR is forced to **TCG on every Apple Silicon generation**: the image's required userspace is 32-bit ARM and Apple CPUs implement no AArch32, so an HVF guest panics at init (see Design Decisions #10). x86 CHR also uses TCG because HVF cannot virtualize x86 on an arm64 host. Override with `--accel hvf` to test a future AArch64-only image.
128
+
118
129
  **Acceleration detection** (`detectAccel`):
119
- - macOS: checks `kern.hv_support` via sysctl; for arm64 guest additionally checks `process.arch === "arm64"` (native bun = Apple Silicon).
130
+ - An explicit override (`--accel` flag > `QUICKCHR_ACCEL` env > `accel` in `quickchr.env`) short-circuits detection entirely; `auto` (the default) runs the checks below.
131
+ - macOS: checks `kern.hv_support` via sysctl. Intel x86 guests get HVF; **all guests on Apple Silicon get TCG** — x86 because HVF cannot virtualize across architectures, arm64 because the CHR image needs AArch32 (Design Decisions #10). A Rosetta process is identified via `sysctl.proc_translated`.
120
132
  - Linux: checks `/dev/kvm` writability.
121
- - Falling back to TCG is always safe, just slower (~20s x86 TCG boot on Apple Silicon; ~2 min arm64 TCG on Intel).
133
+ - Falling back to TCG is safe but can be significantly slower; measure with the
134
+ target host, guest architecture, and RouterOS version.
122
135
 
123
136
  ## CI System
124
137
 
package/MANUAL.md CHANGED
@@ -398,12 +398,18 @@ Precedence per key (highest wins): CLI flag > `QUICKCHR_<KEY>` env var >
398
398
  |---|---|---|---|
399
399
  | `default-channel` | `add`/`start`'s `--channel`, wizard's channel prompt | `stable`\|`long-term`\|`testing`\|`development` | `stable` |
400
400
  | `default-arch` | `add`/`start`'s `--arch`, wizard's arch prompt | `arm64`\|`x86`\|`auto` | `auto` (host native) |
401
+ | `accel` | `add`/`start`'s `--accel`; every `detectAccel()` call in the run | `auto`\|`tcg`\|`hvf`\|`kvm` | `auto` (detect) |
401
402
  | `cache-max-size` | Auto-prune cap applied after each successful `start` | size string (e.g. `2G`, `512M`) | `2G` |
402
403
  | `timeout-extra` | `start`'s `--timeout-extra`/`-T` when omitted | non-negative integer seconds | `0` |
403
404
  | `secure-login` | `add`/`start`'s `--secure-login` when neither `--secure-login` nor `--no-secure-login` is passed | boolean | `unset` (reported as `(unset)` — see note) |
404
405
 
406
+ Anything other than `auto` for `accel` is handed to QEMU verbatim and **skips detection
407
+ entirely**, including the arm64-on-Apple-Silicon TCG fallback (§Platform notes) and the
408
+ availability checks — force `kvm` on macOS and QEMU itself reports the error. That is
409
+ deliberate: the override exists to test an accelerator the detector currently refuses.
410
+
405
411
  `secure-login` is the one key `settings print`/`get` report as `(unset)` rather than a
406
- concrete `false`, unlike the other four: the setup wizard's login prompt needs to tell
412
+ concrete `false`, unlike the others: the setup wizard's login prompt needs to tell
407
413
  "not configured" apart from "explicitly set to false" (it only pre-highlights "Keep
408
414
  admin with no password" when the setting is concretely `false`; leaving it unset keeps
409
415
  the wizard's own recommended "managed login" highlighted). Giving it a real
@@ -412,7 +418,7 @@ the wizard's own recommended "managed login" highlighted). Giving it a real
412
418
  the setting resolves to exactly `true`.
413
419
 
414
420
  ```bash
415
- quickchr settings print # all 5 keys, resolved value + source
421
+ quickchr settings print # all 6 keys, resolved value + source
416
422
  quickchr settings set default-channel testing # write one key to quickchr.env
417
423
  quickchr settings get default-arch # print one key's resolved value
418
424
  quickchr settings reset default-channel # delete one managed line
@@ -420,7 +426,7 @@ quickchr settings reset # clear all managed lines
420
426
  ```
421
427
 
422
428
  Credentials (`--add-user`, `MIKROTIK_WEB_ACCOUNT`/`MIKROTIK_WEB_PASSWORD`) are
423
- never stored here — `settings` only manages the 5 keys above, and does not
429
+ never stored here — `settings` only manages the 6 keys above, and does not
424
430
  mutate any machine's `machine.json`.
425
431
 
426
432
  ### Global behavior
@@ -923,16 +929,20 @@ arch:
923
929
  |---|---|---|
924
930
  | macOS x86_64 | x86 | HVF |
925
931
  | macOS x86_64 | arm64 | TCG |
926
- | macOS arm64 (native bun) | arm64 | HVF |
932
+ | macOS arm64 (native bun) | arm64 | TCG |
927
933
  | macOS arm64 (native bun) | x86 | TCG |
928
934
  | macOS arm64 (Rosetta bun) | arm64 | TCG |
935
+ | macOS arm64 (Rosetta bun) | x86 | TCG |
929
936
  | Linux x86_64 + `/dev/kvm` writable | x86 | KVM |
930
937
  | Linux aarch64 + `/dev/kvm` writable | arm64 | KVM |
931
938
  | any other combo | any | TCG |
932
939
 
933
940
  `Bun` running under Rosetta on Apple Silicon reports `process.arch ===
934
- "x64"`, so HVF for arm64 is skipped (architecture mismatch). Run with
935
- native arm64 Bun to get arm64 HVF.
941
+ "x64"`, so quickchr checks `sysctl.proc_translated` to identify the physical
942
+ arm64 host. Auto-selection uses TCG for both guest architectures there: HVF
943
+ cannot virtualize x86 across architectures, and current arm64 CHR images need
944
+ AArch32 userspace support that Apple Silicon does not provide. Use `--accel`
945
+ (or `QUICKCHR_ACCEL` / the `accel` setting) only to override this deliberately.
936
946
 
937
947
  ### Boot timeout
938
948
 
@@ -996,7 +1006,7 @@ managed via `quickchr settings get|set|print|reset` (§3, Meta). Atomic writes
996
1006
  preserve every other line (comments, blank lines, foreign vars) byte-for-byte.
997
1007
  `reset <key>` deletes the line entirely rather than blanking it, since a blank
998
1008
  `KEY=` line is still "set" to a bash-sourced file. Never stores credentials —
999
- the 5 managed keys are all structural preferences, not secrets.
1009
+ the 6 managed keys are all structural preferences, not secrets.
1000
1010
 
1001
1011
  ### Environment variables consumed
1002
1012
 
@@ -1008,6 +1018,7 @@ the 5 managed keys are all structural preferences, not secrets.
1008
1018
  | `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
1009
1019
  | `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
1010
1020
  | `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
1021
+ | `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
1011
1022
  | `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
1012
1023
  | `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
1013
1024
  | `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
@@ -1255,6 +1266,9 @@ prevents orphan disks accumulating across attempts.
1255
1266
  (MNDP) on the host via a `socket-connect` NIC.
1256
1267
  - **docs/qga-x86-macos-qemu10-investigation.md** — root cause for the
1257
1268
  QGA-on-macOS-arm64 limitation.
1269
+ - **docs/m4-hvf-arm64-investigation.md** — **open** investigation into why arm64
1270
+ CHR panics under HVF on Apple M4 (why arm64 falls back to TCG there); evidence
1271
+ ledger, competing theories, and the test plan to settle them.
1258
1272
  - **`.github/instructions/`** — agent-facing rules (testing, qemu,
1259
1273
  routeros-rest, provisioning, ci, bun-http, general).
1260
1274
  - **`examples/`** — runnable scripts (`bun run`): `quickstart` (single-CHR smoke),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/cli/index.ts CHANGED
@@ -157,6 +157,26 @@ export async function resolveTimeoutExtraMs(flags: Record<string, string | boole
157
157
  return seconds !== undefined ? seconds * 1000 : undefined;
158
158
  }
159
159
 
160
+ /** Apply an explicit `--accel <auto|tcg|hvf|kvm>` as the process-level accelerator
161
+ * override, so every detectAccel() call in this run (QEMU args, boot timeouts,
162
+ * respawn logic) agrees on one value. Absent flag → the accel setting's own
163
+ * env/file/default tiers apply, so this only installs the top tier. Throws
164
+ * INVALID_SETTING_VALUE on an unrecognized mode. Shared by cmdAdd and cmdStart. */
165
+ export async function applyAccelFlag(flags: Record<string, string | boolean | string[]>): Promise<void> {
166
+ const value = flags.accel;
167
+ if (value === undefined) return;
168
+ const raw = Array.isArray(value)
169
+ ? value.at(-1) ?? ""
170
+ : typeof value === "string"
171
+ ? value
172
+ : "";
173
+ const [{ parseAccelMode }, { setAccelOverride }] = await Promise.all([
174
+ import("../lib/types.ts"),
175
+ import("../lib/platform.ts"),
176
+ ]);
177
+ setAccelOverride(parseAccelMode(raw));
178
+ }
179
+
160
180
  /** Resolve --secure-login/--no-secure-login, falling back to the secure-login setting
161
181
  * only when neither flag was passed. Shared by cmdAdd, cmdStart, and cmdStart's
162
182
  * --all bulk-restart path. */
@@ -535,6 +555,7 @@ async function cmdAdd(argv: string[]) {
535
555
  const { QuickCHR } = await import("../lib/quickchr.ts");
536
556
  const { bold, formatPorts, formatNetworks, dim } = await import("./format.ts");
537
557
  const { resolveSetting } = await import("../lib/settings.ts");
558
+ await applyAccelFlag(flags);
538
559
 
539
560
  const opts: StartOptions = {
540
561
  version: flag(flags, "version"),
@@ -1175,6 +1196,8 @@ async function cmdSetup() {
1175
1196
  async function cmdStart(argv: string[]) {
1176
1197
  const { flags, positional } = parseFlags(argv);
1177
1198
  applyTimeoutExtraShortFlag(argv, flags, positional);
1199
+ // Before the --all branch: both start paths must see the same override.
1200
+ await applyAccelFlag(flags);
1178
1201
 
1179
1202
  // --all: start every stopped machine in background
1180
1203
  if (flagBool(flags, "all")) {
@@ -2720,6 +2743,9 @@ Options:
2720
2743
  --arch <arch> Architecture: arm64, x86, auto (default: host native)
2721
2744
  --cpu <n> vCPU count (default: 1)
2722
2745
  --mem <mb> RAM in MB (default: 512)
2746
+ --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
2747
+ Anything but 'auto' bypasses detection, including the
2748
+ arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
2723
2749
  --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2724
2750
  --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2725
2751
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
@@ -2802,6 +2828,9 @@ Options:
2802
2828
  --name <name> Instance name
2803
2829
  --cpu <n> vCPU count (default: 1)
2804
2830
  --mem <mb> RAM in MB (default: 512)
2831
+ --accel <mode> Force QEMU accelerator: auto|tcg|hvf|kvm (default: auto).
2832
+ Anything but 'auto' bypasses detection, including the
2833
+ arm64-on-Apple-Silicon TCG fallback (tikoci/quickchr#97).
2805
2834
  --boot-disk-format <f> Boot disk format: qcow2|raw (default: qcow2)
2806
2835
  --boot-size <size> Resize boot disk (e.g., 512M, 2G) — qcow2 only, requires qemu-img
2807
2836
  --add-disk <size> Add an extra blank qcow2 disk (repeatable, requires qemu-img)
@@ -3056,6 +3085,8 @@ Managed keys:
3056
3085
  default-channel Default --channel for 'add'/'start' and the setup wizard.
3057
3086
  stable|long-term|testing|development (default: stable)
3058
3087
  default-arch Default --arch. arm64|x86|auto (default: auto = host native)
3088
+ accel QEMU accelerator for 'add'/'start'.
3089
+ auto|tcg|hvf|kvm (default: auto = detect)
3059
3090
  cache-max-size Auto-prune cap applied after each successful start.
3060
3091
  Size string (e.g. 2G, 512M). (default: 2G)
3061
3092
  timeout-extra Default for 'start's --timeout-extra/-T when omitted.
@@ -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
  }
@@ -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,
@@ -1526,6 +1526,8 @@ export class QuickCHR {
1526
1526
  // Build QEMU args and spawn
1527
1527
  const platform = await detectPlatform();
1528
1528
  const accel = await detectAccel(arch);
1529
+ const note = accelNote(arch, accel);
1530
+ if (note) logger.warn(note);
1529
1531
  registerSocketMembers(state);
1530
1532
  const hostfwd = buildHostfwdString(state.ports);
1531
1533
  const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
@@ -1838,6 +1840,8 @@ export class QuickCHR {
1838
1840
 
1839
1841
  const platform = await detectPlatform();
1840
1842
  const accel = await detectAccel(state.arch);
1843
+ const note = accelNote(state.arch, accel);
1844
+ if (note) (logger ?? createLogger()).warn(note);
1841
1845
  registerSocketMembers(state);
1842
1846
  const hostfwd = buildHostfwdString(state.ports);
1843
1847
  const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
@@ -2001,10 +2005,14 @@ export class QuickCHR {
2001
2005
  try {
2002
2006
  const platform = await detectPlatform();
2003
2007
  if (platform.accelAvailable.length > 0) {
2008
+ // Under an override, accelAvailable is just the forced mode for every
2009
+ // arch — say so, or the row reads as a capability report it isn't.
2010
+ const { mode: forced, source } = resolveAccelOverrideWithSource();
2011
+ const suffix = forced === "auto" ? "" : ` — configured override via ${accelSourceLabel(source)}`;
2004
2012
  checks.push({
2005
2013
  label: "Acceleration",
2006
2014
  status: "ok",
2007
- detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})`,
2015
+ detail: `${platform.accelAvailable.join(", ")} (host: ${platform.hostArch})${suffix}`,
2008
2016
  });
2009
2017
  } else {
2010
2018
  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
@@ -13,6 +13,28 @@ export const CHANNELS: readonly Channel[] = Object.freeze(["stable", "long-term"
13
13
  export type Arch = "arm64" | "x86";
14
14
  export const ARCHES: Arch[] = ["arm64", "x86"];
15
15
 
16
+ // --- Acceleration ---
17
+
18
+ /**
19
+ * QEMU accelerator selection. "auto" means detectAccel() picks, including its
20
+ * platform safety fallbacks; the other three force `-accel <mode>` verbatim and
21
+ * bypass detection entirely (see DESIGN.md #10).
22
+ */
23
+ export type AccelMode = "auto" | "tcg" | "hvf" | "kvm";
24
+ export const ACCEL_MODES: readonly AccelMode[] = Object.freeze(["auto", "tcg", "hvf", "kvm"] as AccelMode[]);
25
+
26
+ /** Validate an accelerator name from a CLI flag, env var, or settings file. */
27
+ export function parseAccelMode(raw: string): AccelMode {
28
+ const v = raw.trim().toLowerCase();
29
+ if (!(ACCEL_MODES as readonly string[]).includes(v)) {
30
+ throw new QuickCHRError(
31
+ "INVALID_SETTING_VALUE",
32
+ `Invalid value "${raw}" for accel — must be one of ${ACCEL_MODES.join(", ")}`,
33
+ );
34
+ }
35
+ return v as AccelMode;
36
+ }
37
+
16
38
  // --- Networking ---
17
39
 
18
40
  /** @deprecated Use NetworkSpecifier instead. Kept for backward compatibility with old machine.json files. */