@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.
- package/DESIGN.md +260 -7
- package/MANUAL.md +44 -12
- package/package.json +1 -1
- package/src/cli/index.ts +31 -0
- package/src/index.ts +1 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +3 -39
- package/src/lib/net.ts +59 -3
- package/src/lib/packages.ts +6 -12
- package/src/lib/platform.ts +153 -19
- package/src/lib/qemu.ts +23 -4
- package/src/lib/quickchr.ts +266 -26
- package/src/lib/settings-file.ts +52 -0
- package/src/lib/settings.ts +20 -42
- package/src/lib/types.ts +37 -1
- package/src/lib/versions.ts +99 -22
package/src/lib/platform.ts
CHANGED
|
@@ -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
|
|
115
|
-
* Cross-arch
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
|
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
|
}
|