@tikoci/quickchr 0.5.1 → 0.5.3
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/.bun-version +1 -0
- package/DESIGN.md +39 -1
- package/MANUAL.md +97 -21
- package/README.md +7 -10
- package/package.json +2 -1
- package/src/cli/flags.ts +4 -1
- package/src/cli/index.ts +148 -14
- package/src/cli/wizard.ts +54 -58
- package/src/index.ts +1 -0
- package/src/lib/completions.ts +9 -6
- package/src/lib/lock.ts +1 -0
- package/src/lib/platform.ts +13 -1
- package/src/lib/qemu.ts +31 -4
- package/src/lib/quickchr.ts +179 -15
- package/src/lib/state.ts +1 -0
- package/src/lib/types.ts +48 -1
package/src/lib/qemu.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface QemuLaunchConfig {
|
|
|
38
38
|
* can be used for both QEMU args and boot timeout calculation.
|
|
39
39
|
*/
|
|
40
40
|
accel?: string;
|
|
41
|
+
/** Extra argv elements (`StartOptions.qemuArgs`), appended verbatim after
|
|
42
|
+
* quickchr's own args. Ordering is load-bearing: last-wins on conflicts. */
|
|
43
|
+
qemuArgs?: string[];
|
|
44
|
+
/** Hold the guest at reset (`-S`) with a gdbstub on `<machineDir>/gdb.sock`
|
|
45
|
+
* (`start --frozen`). Unix socket, `server=on,wait=off` so the spawn never
|
|
46
|
+
* blocks waiting for a client. */
|
|
47
|
+
frozen?: boolean;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/** Build the full QEMU command-line arguments array. */
|
|
@@ -136,6 +143,15 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
|
|
|
136
143
|
// Serial, monitor, QGA channels
|
|
137
144
|
buildChannelArgs(args, arch, machineDir, background, portBase);
|
|
138
145
|
|
|
146
|
+
// Frozen boot (#214): hold at reset with a gdbstub. Before the passthrough
|
|
147
|
+
// below, which is always last.
|
|
148
|
+
if (config.frozen) {
|
|
149
|
+
args.push("-S", "-gdb", `unix:${join(machineDir, "gdb.sock")},server=on,wait=off`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Lab escape hatch (#213): verbatim passthrough, always last so it wins conflicts.
|
|
153
|
+
if (config.qemuArgs) args.push(...config.qemuArgs);
|
|
154
|
+
|
|
139
155
|
return args;
|
|
140
156
|
}
|
|
141
157
|
|
|
@@ -471,8 +487,17 @@ export async function spawnQemu(
|
|
|
471
487
|
|
|
472
488
|
const pid = proc.pid;
|
|
473
489
|
writeFileSync(pidPath, String(pid));
|
|
474
|
-
await proc.exited;
|
|
490
|
+
const exitCode = await proc.exited;
|
|
475
491
|
try { unlinkSync(pidPath); } catch { /* ignore */ }
|
|
492
|
+
// A bad argv element (e.g. --qemu-arg) fails fast here with a QEMU error on
|
|
493
|
+
// the terminal — but without this check the caller reports a clean stop.
|
|
494
|
+
// Only a numeric non-zero code throws: a signal kill (null) is a user quit.
|
|
495
|
+
if (typeof exitCode === "number" && exitCode !== 0) {
|
|
496
|
+
throw new QuickCHRError(
|
|
497
|
+
"SPAWN_FAILED",
|
|
498
|
+
`QEMU exited with status ${exitCode} — see the QEMU error output above (often a bad --qemu-arg value).`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
476
501
|
return { pid };
|
|
477
502
|
}
|
|
478
503
|
|
|
@@ -505,11 +530,13 @@ export async function stopQemu(pid: number): Promise<boolean> {
|
|
|
505
530
|
return true;
|
|
506
531
|
}
|
|
507
532
|
|
|
508
|
-
/** Remove the listening unix-socket files QEMU binds (monitor/serial/qga).
|
|
533
|
+
/** Remove the listening unix-socket files QEMU binds (monitor/serial/qga/gdb).
|
|
509
534
|
* A SIGKILLed QEMU leaves these behind; a respawn with `server=on` then fails
|
|
510
|
-
* to bind ("Address already in use"). Call before re-spawning into the same dir.
|
|
535
|
+
* to bind ("Address already in use"). Call before re-spawning into the same dir.
|
|
536
|
+
* `gdb.sock` is included: a stale one from a frozen boot would otherwise linger
|
|
537
|
+
* after stop and refuse a harness attach on a later non-frozen boot. */
|
|
511
538
|
export function cleanupQemuSockets(machineDir: string): void {
|
|
512
|
-
for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
|
|
539
|
+
for (const sock of ["monitor.sock", "serial.sock", "qga.sock", "gdb.sock"]) {
|
|
513
540
|
const path = join(machineDir, sock);
|
|
514
541
|
try {
|
|
515
542
|
if (existsSync(path)) unlinkSync(path);
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
Channel,
|
|
8
8
|
ChrInstance,
|
|
9
9
|
ChrLoadSample,
|
|
10
|
+
ContinueOptions,
|
|
10
11
|
CustomForward,
|
|
11
12
|
Descriptor,
|
|
12
13
|
DeviceModeOptions,
|
|
@@ -30,7 +31,7 @@ import type {
|
|
|
30
31
|
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP, PORTS_PER_BLOCK } from "./types.ts";
|
|
31
32
|
import { assertValidResourceName, assertPathSafeName, shellQuote } from "./names.ts";
|
|
32
33
|
import packageJson from "../../package.json";
|
|
33
|
-
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
34
|
+
import { bunRuntimeCheck, detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
34
35
|
import {
|
|
35
36
|
resolveVersion,
|
|
36
37
|
isValidVersion,
|
|
@@ -211,6 +212,18 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
|
|
|
211
212
|
return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** Per-boot launch intent for `_launchExisting`: session `qemuArgs` and `frozen`
|
|
216
|
+
* when given, used for the spawn but never persisted.
|
|
217
|
+
* Absent (`undefined`) means "not given", and only absent falls through to what
|
|
218
|
+
* `add` recorded — the same rule as `pendingProvisioningFor` (#185): an empty
|
|
219
|
+
* array is not an instruction to drop the stored args. */
|
|
220
|
+
function sessionLaunch(opts: StartOptions): { qemuArgs?: string[]; frozen?: boolean } | undefined {
|
|
221
|
+
const launch: { qemuArgs?: string[]; frozen?: boolean } = {};
|
|
222
|
+
if (opts.qemuArgs?.length) launch.qemuArgs = [...opts.qemuArgs];
|
|
223
|
+
if (opts.frozen) launch.frozen = true;
|
|
224
|
+
return launch.qemuArgs || launch.frozen ? launch : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
/** `wait: false` returns before the guest has booted, and provisioning needs a booted
|
|
215
228
|
* guest — so the pair is refused by name rather than the provisioning silently
|
|
216
229
|
* skipped (#159). `machine` is set when the provisioning is first-boot intent that
|
|
@@ -227,6 +240,20 @@ function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, mac
|
|
|
227
240
|
);
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
/** `frozen` returns before the guest has booted, like `wait: false` — and
|
|
244
|
+
* provisioning needs a booted guest, so the pair is refused by name (#214). */
|
|
245
|
+
function assertFrozenAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
|
|
246
|
+
if (!opts.frozen) return;
|
|
247
|
+
const source = machine
|
|
248
|
+
? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
|
|
249
|
+
: `provisioning was requested (${operation})`;
|
|
250
|
+
throw new QuickCHRError(
|
|
251
|
+
"INVALID_ARGUMENT",
|
|
252
|
+
`--frozen (frozen: true) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so a frozen start cannot provision it. ` +
|
|
253
|
+
"Drop --frozen for this first boot — later starts can use it.",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
230
257
|
// --- Socket registry lifecycle helpers ---
|
|
231
258
|
|
|
232
259
|
function getSocketNamedNetworks(state: MachineState): string[] {
|
|
@@ -456,15 +483,104 @@ async function stopUnderLock(state: MachineState): Promise<void> {
|
|
|
456
483
|
/** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
|
|
457
484
|
function finalizeStoppedState(state: MachineState): void {
|
|
458
485
|
unregisterSocketMembers(state);
|
|
459
|
-
//
|
|
486
|
+
// A frozen marker belongs to a live QEMU held at reset — a stopped machine
|
|
487
|
+
// holds nothing, so the flag goes with the pid (#214).
|
|
460
488
|
const current = loadMachine(state.name);
|
|
461
489
|
if (current) {
|
|
462
490
|
current.status = "stopped";
|
|
463
491
|
current.pid = undefined;
|
|
492
|
+
current.frozen = undefined;
|
|
464
493
|
saveMachine(current);
|
|
465
494
|
}
|
|
466
495
|
state.status = "stopped";
|
|
467
496
|
state.pid = undefined;
|
|
497
|
+
state.frozen = undefined;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Drop the frozen marker after a resume, in state and on disk. Dual-write like
|
|
501
|
+
* the provisioning record: the caller's handle and the next reader agree. */
|
|
502
|
+
function clearFrozenMarker(state: MachineState): void {
|
|
503
|
+
const current = loadMachine(state.name);
|
|
504
|
+
if (current) {
|
|
505
|
+
current.frozen = undefined;
|
|
506
|
+
saveMachine(current);
|
|
507
|
+
}
|
|
508
|
+
state.frozen = undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Best-effort QEMU argv for a continue-timeout forensics report: rebuilt from
|
|
512
|
+
* stored state. `wasFrozen` is the marker as it stood before this resume — the
|
|
513
|
+
* marker is already cleared by the time a timeout is captured, and only a truly
|
|
514
|
+
* frozen spawn carried `-S`/`-gdb`. A session `--qemu-arg` override from that
|
|
515
|
+
* start is not reflected — the report is context, and the override was
|
|
516
|
+
* deliberately never persisted. */
|
|
517
|
+
async function launchedQemuArgsForReport(state: MachineState, wasFrozen: boolean): Promise<string[]> {
|
|
518
|
+
try {
|
|
519
|
+
return await buildQemuArgs({ ...(await buildLaunchConfigFromState(state)), frozen: wasFrozen });
|
|
520
|
+
} catch {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The body of `continue()`, for a caller that already holds the machine lock. */
|
|
526
|
+
async function continueUnderLock(state: MachineState, wait: boolean, timeoutExtra?: number): Promise<void> {
|
|
527
|
+
const pid = state.pid;
|
|
528
|
+
if (!pid || !isMachineRunning(state)) {
|
|
529
|
+
// Leave clean state behind: the marker belonged to a QEMU that is gone.
|
|
530
|
+
finalizeStoppedState(state);
|
|
531
|
+
throw new QuickCHRError(
|
|
532
|
+
"MACHINE_STOPPED",
|
|
533
|
+
`Machine "${state.name}" is not running — nothing to continue.`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
// Idempotent: gdb (or an earlier continue) may have resumed the guest already.
|
|
537
|
+
const status = await monitorCommand(state.machineDir, "info status", undefined, state.portBase);
|
|
538
|
+
if (!status.includes("running")) {
|
|
539
|
+
const contOut = await monitorCommand(state.machineDir, "cont", undefined, state.portBase);
|
|
540
|
+
// A QEMU-level `cont` error is the answer, not a cue to wait: the guest
|
|
541
|
+
// never resumed, so the marker stays and no boot wait is burned. Same
|
|
542
|
+
// /^error/i contract as savevm/loadvm/delvm.
|
|
543
|
+
if (/^error[:\s]/i.test(contOut)) {
|
|
544
|
+
throw new QuickCHRError("PROCESS_FAILED", `continue failed: ${contOut.trim() || "(empty)"}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const wasFrozen = state.frozen === true;
|
|
548
|
+
clearFrozenMarker(state);
|
|
549
|
+
if (!wait) return;
|
|
550
|
+
|
|
551
|
+
const instance = createInstance(state);
|
|
552
|
+
// The frozen launch's accelerator when recorded: detectAccel() prefers the
|
|
553
|
+
// current process's override, which may differ from the launch's.
|
|
554
|
+
const accel = state.lastAccel ?? await detectAccel(state.arch);
|
|
555
|
+
const bootTimeout = defaultBootTimeout(state.arch, undefined, accel) + (timeoutExtra ?? 0);
|
|
556
|
+
const probe = newBootProbeStats();
|
|
557
|
+
if (await instance.waitForBoot(bootTimeout, probe)) return;
|
|
558
|
+
|
|
559
|
+
const guest = bootFailureGuestExec(state);
|
|
560
|
+
const failure = await captureBootFailure({
|
|
561
|
+
name: state.name,
|
|
562
|
+
machineDir: state.machineDir,
|
|
563
|
+
arch: state.arch,
|
|
564
|
+
accel,
|
|
565
|
+
bootTimeoutMs: bootTimeout,
|
|
566
|
+
pid,
|
|
567
|
+
httpPort: toChrPorts(state.ports).http,
|
|
568
|
+
portBase: state.portBase,
|
|
569
|
+
qemuArgs: await launchedQemuArgsForReport(state, wasFrozen),
|
|
570
|
+
probe,
|
|
571
|
+
phase: "continue",
|
|
572
|
+
reportDir: bootFailureReportDir(),
|
|
573
|
+
monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
|
|
574
|
+
forwards: Object.values(state.ports),
|
|
575
|
+
guestExec: guest?.exec,
|
|
576
|
+
guestUser: guest?.users.join(" → "),
|
|
577
|
+
});
|
|
578
|
+
throw new QuickCHRError(
|
|
579
|
+
"BOOT_TIMEOUT",
|
|
580
|
+
`CHR did not respond within ${bootTimeout / 1000}s (accel=${accel}) after continue.` +
|
|
581
|
+
await cleanupAfterBootFailure(instance, state.name, failure, { kind: "preserve-existing" }) +
|
|
582
|
+
`\n${failure.summary}`,
|
|
583
|
+
);
|
|
468
584
|
}
|
|
469
585
|
|
|
470
586
|
/** The body of `remove()`, for a caller that already holds the machine lock. The lock
|
|
@@ -521,6 +637,7 @@ async function cleanUnderLock(state: MachineState): Promise<void> {
|
|
|
521
637
|
if (current) {
|
|
522
638
|
current.status = "stopped";
|
|
523
639
|
current.pid = undefined;
|
|
640
|
+
current.frozen = undefined;
|
|
524
641
|
current.user = undefined;
|
|
525
642
|
current.managedSshKey = undefined;
|
|
526
643
|
current.disableAdmin = undefined;
|
|
@@ -532,6 +649,7 @@ async function cleanUnderLock(state: MachineState): Promise<void> {
|
|
|
532
649
|
}
|
|
533
650
|
state.status = "stopped";
|
|
534
651
|
state.pid = undefined;
|
|
652
|
+
state.frozen = undefined;
|
|
535
653
|
state.user = undefined;
|
|
536
654
|
state.managedSshKey = undefined;
|
|
537
655
|
state.disableAdmin = undefined;
|
|
@@ -770,6 +888,10 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
770
888
|
});
|
|
771
889
|
},
|
|
772
890
|
|
|
891
|
+
async continue(options?: ContinueOptions): Promise<void> {
|
|
892
|
+
await withMachineLock(state, "continue", () => continueUnderLock(state, options?.wait ?? true, options?.timeoutExtra));
|
|
893
|
+
},
|
|
894
|
+
|
|
773
895
|
async remove(): Promise<void> {
|
|
774
896
|
await withMachineLock(state, "remove", () => removeUnderLock(state));
|
|
775
897
|
},
|
|
@@ -1575,6 +1697,9 @@ async function hardRebootMachine(
|
|
|
1575
1697
|
state.pid = pid;
|
|
1576
1698
|
state.status = "running";
|
|
1577
1699
|
state.lastStartedAt = new Date().toISOString();
|
|
1700
|
+
// A power-cycle always boots normally: the replacement QEMU carries no -S,
|
|
1701
|
+
// so a frozen marker from before must not survive it.
|
|
1702
|
+
state.frozen = undefined;
|
|
1578
1703
|
saveMachine(state);
|
|
1579
1704
|
|
|
1580
1705
|
return method;
|
|
@@ -1609,6 +1734,7 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
|
|
|
1609
1734
|
networks: resolvedNetworks,
|
|
1610
1735
|
background: true,
|
|
1611
1736
|
portBase: state.portBase,
|
|
1737
|
+
qemuArgs: state.qemuArgs,
|
|
1612
1738
|
};
|
|
1613
1739
|
}
|
|
1614
1740
|
|
|
@@ -2078,6 +2204,7 @@ export class QuickCHR {
|
|
|
2078
2204
|
bootSize: diskOpts.bootSize,
|
|
2079
2205
|
extraDisks: diskOpts.extraDisks,
|
|
2080
2206
|
bootDiskFormat: diskArtifacts.bootDisk.format,
|
|
2207
|
+
qemuArgs: opts.qemuArgs?.length ? [...opts.qemuArgs] : undefined,
|
|
2081
2208
|
createdAt: new Date().toISOString(),
|
|
2082
2209
|
status: "stopped",
|
|
2083
2210
|
machineDir,
|
|
@@ -2108,8 +2235,9 @@ export class QuickCHR {
|
|
|
2108
2235
|
* Start a new or existing CHR instance.
|
|
2109
2236
|
*
|
|
2110
2237
|
* The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
|
|
2111
|
-
* device-mode, user) has completed before this promise resolves.
|
|
2112
|
-
* `wait: false` resolves once QEMU is running and leaves readiness to the caller,
|
|
2238
|
+
* device-mode, user) has completed before this promise resolves. Three exceptions:
|
|
2239
|
+
* `wait: false` resolves once QEMU is running and leaves readiness to the caller,
|
|
2240
|
+
* `frozen: true` additionally holds the guest at reset for `continue()`, and
|
|
2113
2241
|
* a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
|
|
2114
2242
|
* Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
|
|
2115
2243
|
* stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
|
|
@@ -2131,6 +2259,13 @@ export class QuickCHR {
|
|
|
2131
2259
|
);
|
|
2132
2260
|
}
|
|
2133
2261
|
|
|
2262
|
+
if (opts.frozen && opts.background === false) {
|
|
2263
|
+
throw new QuickCHRError(
|
|
2264
|
+
"INVALID_ARGUMENT",
|
|
2265
|
+
"--frozen (frozen: true) cannot be combined with --fg (background: false): a frozen start holds the guest at reset for gdb and a later continue, and a foreground start holds the serial console until QEMU exits instead.",
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2134
2269
|
const logger = createLogger(opts.onProgress);
|
|
2135
2270
|
|
|
2136
2271
|
// Before any download or disk work: a named socket whose two ends are already
|
|
@@ -2176,6 +2311,7 @@ export class QuickCHR {
|
|
|
2176
2311
|
if (hasProvisioningMutations(mutations)) {
|
|
2177
2312
|
assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
|
|
2178
2313
|
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
|
|
2314
|
+
assertFrozenAllowsProvisioning(opts, describeProvisioningOperation(mutations));
|
|
2179
2315
|
}
|
|
2180
2316
|
|
|
2181
2317
|
// Resolve architecture
|
|
@@ -2254,13 +2390,14 @@ export class QuickCHR {
|
|
|
2254
2390
|
};
|
|
2255
2391
|
assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
|
|
2256
2392
|
assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
|
|
2393
|
+
assertFrozenAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
|
|
2257
2394
|
requireHostFor(existing.arch);
|
|
2258
|
-
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
|
|
2395
|
+
return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger, opts.wait ?? true, sessionLaunch(opts));
|
|
2259
2396
|
}
|
|
2260
2397
|
}
|
|
2261
2398
|
// Exists but stopped — restart it
|
|
2262
2399
|
requireHostFor(existing.arch);
|
|
2263
|
-
return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
|
|
2400
|
+
return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true, sessionLaunch(opts));
|
|
2264
2401
|
}
|
|
2265
2402
|
|
|
2266
2403
|
assertRouterOs6Arch(version, arch);
|
|
@@ -2292,6 +2429,7 @@ export class QuickCHR {
|
|
|
2292
2429
|
bootSize: diskOpts.bootSize,
|
|
2293
2430
|
extraDisks: diskOpts.extraDisks,
|
|
2294
2431
|
bootDiskFormat: diskOpts.bootDiskFormat,
|
|
2432
|
+
qemuArgs: opts.qemuArgs?.length ? [...opts.qemuArgs] : undefined,
|
|
2295
2433
|
createdAt: new Date().toISOString(),
|
|
2296
2434
|
status: "stopped",
|
|
2297
2435
|
machineDir,
|
|
@@ -2384,6 +2522,7 @@ export class QuickCHR {
|
|
|
2384
2522
|
bootSize: diskOpts.bootSize,
|
|
2385
2523
|
extraDisks: diskOpts.extraDisks,
|
|
2386
2524
|
bootDiskFormat: bootDisk.format,
|
|
2525
|
+
qemuArgs: opts.qemuArgs?.length ? [...opts.qemuArgs] : undefined,
|
|
2387
2526
|
createdAt: new Date().toISOString(),
|
|
2388
2527
|
status: "running",
|
|
2389
2528
|
machineDir,
|
|
@@ -2415,6 +2554,8 @@ export class QuickCHR {
|
|
|
2415
2554
|
background: spawnInBackground,
|
|
2416
2555
|
portBase: state.portBase,
|
|
2417
2556
|
accel,
|
|
2557
|
+
qemuArgs: state.qemuArgs,
|
|
2558
|
+
frozen: opts.frozen,
|
|
2418
2559
|
};
|
|
2419
2560
|
|
|
2420
2561
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
@@ -2438,12 +2579,21 @@ export class QuickCHR {
|
|
|
2438
2579
|
|
|
2439
2580
|
state.pid = pid;
|
|
2440
2581
|
state.lastStartedAt = new Date().toISOString();
|
|
2582
|
+
// Frozen skips the boot wait entirely and returns after the spawn
|
|
2583
|
+
// early-exit check — by construction it can never produce BOOT_TIMEOUT.
|
|
2584
|
+
// It also skips the post-boot lastAccel recording below, so record the
|
|
2585
|
+
// launch accelerator here for continue()'s resume timeout.
|
|
2586
|
+
if (opts.frozen) {
|
|
2587
|
+
state.frozen = true;
|
|
2588
|
+
state.lastAccel = accel;
|
|
2589
|
+
}
|
|
2441
2590
|
|
|
2442
2591
|
saveMachine(state);
|
|
2443
2592
|
unpersistedSocketClaim = undefined;
|
|
2444
2593
|
storageLease.release();
|
|
2445
2594
|
|
|
2446
2595
|
const instance = createInstance(state);
|
|
2596
|
+
if (opts.frozen) return instance;
|
|
2447
2597
|
if (opts.wait === false) return instance;
|
|
2448
2598
|
|
|
2449
2599
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
@@ -2717,7 +2867,11 @@ export class QuickCHR {
|
|
|
2717
2867
|
recordProvisioning(machineState, applied);
|
|
2718
2868
|
}
|
|
2719
2869
|
|
|
2720
|
-
/** Re-launch an existing stopped machine.
|
|
2870
|
+
/** Re-launch an existing stopped machine.
|
|
2871
|
+
*
|
|
2872
|
+
* `launch` carries per-boot intent that is used for this spawn but never
|
|
2873
|
+
* persisted: session `qemuArgs` replace the stored ones for that boot, and
|
|
2874
|
+
* `frozen` holds the guest at reset for a later `continue()`. */
|
|
2721
2875
|
static async _launchExisting(
|
|
2722
2876
|
state: MachineState,
|
|
2723
2877
|
background: boolean,
|
|
@@ -2732,6 +2886,7 @@ export class QuickCHR {
|
|
|
2732
2886
|
},
|
|
2733
2887
|
logger?: ProgressLogger,
|
|
2734
2888
|
wait = true,
|
|
2889
|
+
launch?: { qemuArgs?: string[]; frozen?: boolean },
|
|
2735
2890
|
): Promise<ChrInstance> {
|
|
2736
2891
|
const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
|
|
2737
2892
|
opts && (
|
|
@@ -2812,6 +2967,9 @@ export class QuickCHR {
|
|
|
2812
2967
|
unpersistedSocketClaim = state;
|
|
2813
2968
|
reportSocketTransports(state, logger ?? createLogger());
|
|
2814
2969
|
|
|
2970
|
+
// Session passthrough replaces stored args for this boot only — never written
|
|
2971
|
+
// back to state, so the next plain start reuses what `add` recorded (#213).
|
|
2972
|
+
const effectiveQemuArgs = launch?.qemuArgs?.length ? launch.qemuArgs : state.qemuArgs;
|
|
2815
2973
|
const launchConfig: QemuLaunchConfig = {
|
|
2816
2974
|
arch: state.arch,
|
|
2817
2975
|
machineDir: state.machineDir,
|
|
@@ -2824,6 +2982,8 @@ export class QuickCHR {
|
|
|
2824
2982
|
background: spawnBackground,
|
|
2825
2983
|
portBase: state.portBase,
|
|
2826
2984
|
accel,
|
|
2985
|
+
qemuArgs: effectiveQemuArgs,
|
|
2986
|
+
frozen: launch?.frozen,
|
|
2827
2987
|
};
|
|
2828
2988
|
|
|
2829
2989
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
@@ -2845,10 +3005,18 @@ export class QuickCHR {
|
|
|
2845
3005
|
state.pid = pid;
|
|
2846
3006
|
state.status = "running";
|
|
2847
3007
|
state.lastStartedAt = new Date().toISOString();
|
|
3008
|
+
// The marker describes this spawn, not the machine: a relaunch that is
|
|
3009
|
+
// not frozen must not inherit a stale one (e.g. a kill -9'd frozen
|
|
3010
|
+
// machine rebooted normally still listing as frozen).
|
|
3011
|
+
state.frozen = launch?.frozen ? true : undefined;
|
|
3012
|
+
// Frozen returns before the post-boot lastAccel recording below, so a
|
|
3013
|
+
// frozen relaunch records the launch accelerator for continue().
|
|
3014
|
+
if (launch?.frozen) state.lastAccel = accel;
|
|
2848
3015
|
saveMachine(state);
|
|
2849
3016
|
unpersistedSocketClaim = undefined;
|
|
2850
3017
|
|
|
2851
3018
|
const instance = createInstance(state);
|
|
3019
|
+
if (launch?.frozen) return instance;
|
|
2852
3020
|
if (!wait) return instance;
|
|
2853
3021
|
|
|
2854
3022
|
// Always wait for boot in background mode — start() promises "REST-ready".
|
|
@@ -2939,6 +3107,7 @@ export class QuickCHR {
|
|
|
2939
3107
|
if (state.status === "running" && !isMachineRunning(state)) {
|
|
2940
3108
|
state.status = "stopped";
|
|
2941
3109
|
state.pid = undefined;
|
|
3110
|
+
state.frozen = undefined;
|
|
2942
3111
|
saveMachine(state);
|
|
2943
3112
|
}
|
|
2944
3113
|
|
|
@@ -2997,12 +3166,7 @@ export class QuickCHR {
|
|
|
2997
3166
|
static async doctor(): Promise<DoctorResult> {
|
|
2998
3167
|
const checks: DoctorResult["checks"] = [];
|
|
2999
3168
|
|
|
3000
|
-
|
|
3001
|
-
checks.push({
|
|
3002
|
-
label: "Bun runtime",
|
|
3003
|
-
status: "ok",
|
|
3004
|
-
detail: `bun ${Bun.version}`,
|
|
3005
|
-
});
|
|
3169
|
+
checks.push(await bunRuntimeCheck());
|
|
3006
3170
|
|
|
3007
3171
|
// QEMU for each arch
|
|
3008
3172
|
for (const arch of ARCHES) {
|
|
@@ -3125,7 +3289,7 @@ export class QuickCHR {
|
|
|
3125
3289
|
});
|
|
3126
3290
|
}
|
|
3127
3291
|
|
|
3128
|
-
// qemu-img (
|
|
3292
|
+
// qemu-img (default qcow2 creation and disk features)
|
|
3129
3293
|
const qemuImgPath = findQemuImg();
|
|
3130
3294
|
if (qemuImgPath) {
|
|
3131
3295
|
checks.push({
|
|
@@ -3137,7 +3301,7 @@ export class QuickCHR {
|
|
|
3137
3301
|
checks.push({
|
|
3138
3302
|
label: "qemu-img",
|
|
3139
3303
|
status: "warn",
|
|
3140
|
-
detail: `not found — required for --boot-size and --add-disk (${getQemuInstallHint()})`,
|
|
3304
|
+
detail: `not found — required for default qcow2 creation, setup, --boot-size and --add-disk (${getQemuInstallHint()})`,
|
|
3141
3305
|
});
|
|
3142
3306
|
}
|
|
3143
3307
|
|
package/src/lib/state.ts
CHANGED
package/src/lib/types.ts
CHANGED
|
@@ -249,6 +249,10 @@ export interface MachineConfig {
|
|
|
249
249
|
extraDisks?: string[];
|
|
250
250
|
/** Format of the boot disk — raw by default, qcow2 when resized. */
|
|
251
251
|
bootDiskFormat?: BootDiskFormat;
|
|
252
|
+
/** Extra QEMU argv elements recorded by `add --qemu-arg`, reused on every boot
|
|
253
|
+
* unless a `start` call passes its own `qemuArgs` for that session (session
|
|
254
|
+
* replaces stored for that boot). Appended verbatim after quickchr's own args. */
|
|
255
|
+
qemuArgs?: string[];
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
/** One provisioning step, as recorded on {@link ProvisioningRecord.steps}. */
|
|
@@ -311,6 +315,11 @@ export interface MachineState extends MachineConfig {
|
|
|
311
315
|
cleanedAt?: string;
|
|
312
316
|
status: "stopped" | "running" | "error";
|
|
313
317
|
pid?: number;
|
|
318
|
+
/** Live marker: QEMU was started with `frozen: true` (`start --frozen`) and is
|
|
319
|
+
* held at reset (`-S`), awaiting `continue()`. Per-boot intent like
|
|
320
|
+
* `wait: false`, not persisted config — stopping clears it, and a later
|
|
321
|
+
* plain `start` boots normally. `status` stays `"running"` while frozen. */
|
|
322
|
+
frozen?: boolean;
|
|
314
323
|
/** Path to the machine directory. */
|
|
315
324
|
machineDir: string;
|
|
316
325
|
/** Accelerator resolved for the most recent boot ("kvm" | "hvf" | "tcg"). */
|
|
@@ -457,10 +466,31 @@ export interface StartOptions {
|
|
|
457
466
|
/** Extra milliseconds added to the computed boot timeout.
|
|
458
467
|
* Useful on slow hosts where `defaultBootTimeout` underestimates actual boot time. */
|
|
459
468
|
timeoutExtra?: number;
|
|
469
|
+
/** Extra QEMU argv elements, one array entry per argv element, appended verbatim
|
|
470
|
+
* (no splitting, no validation) after quickchr's own args.
|
|
471
|
+
*
|
|
472
|
+
* On `add()` this is recorded to `machine.json` and reused every boot. On
|
|
473
|
+
* `start()` of an existing machine it applies to that session only: when both
|
|
474
|
+
* stored and session args exist, the session value replaces the stored one for
|
|
475
|
+
* that boot (predictable; avoids duplicate-ID errors like two `-audiodev`
|
|
476
|
+
* with the same id). When `start()` creates the machine, the value is both
|
|
477
|
+
* used for that boot and recorded, like every other creation option.
|
|
478
|
+
*
|
|
479
|
+
* Ordering is load-bearing: passthrough goes last, so conflicting QEMU options
|
|
480
|
+
* resolve last-wins (e.g. a duplicate `-M pc,...` merges over quickchr's `-M pc`).
|
|
481
|
+
* A bad element surfaces as `SPAWN_FAILED` with the QEMU log tail. */
|
|
482
|
+
qemuArgs?: string[];
|
|
460
483
|
/** Progress callback for status messages during start.
|
|
461
484
|
* If not provided, messages go to console.log.
|
|
462
485
|
* Debug-level messages (prefixed [debug]) are only emitted when QUICKCHR_DEBUG=1. */
|
|
463
486
|
onProgress?: (message: string) => void;
|
|
487
|
+
/** Boot frozen at reset (`-S`) with a gdbstub on `<machineDir>/gdb.sock`,
|
|
488
|
+
* for harnesses that must touch the guest before RouterOS init runs (e.g.
|
|
489
|
+
* RAM patching via gdb). Skips the boot wait entirely and resolves after
|
|
490
|
+
* the spawn early-exit check, like `wait: false` — resume with
|
|
491
|
+
* `continue()`. Refused with provisioning and with `background: false`.
|
|
492
|
+
* Per-boot intent: never persisted, so a later plain `start` boots normally. */
|
|
493
|
+
frozen?: boolean;
|
|
464
494
|
}
|
|
465
495
|
|
|
466
496
|
// --- Instance (runtime handle) ---
|
|
@@ -631,6 +661,18 @@ export interface ShutdownResult {
|
|
|
631
661
|
requestError?: string;
|
|
632
662
|
}
|
|
633
663
|
|
|
664
|
+
/** Options for {@link ChrInstance.continue}. */
|
|
665
|
+
export interface ContinueOptions {
|
|
666
|
+
/** Wait for REST-ready before resolving (true, default), mirroring `start()`'s
|
|
667
|
+
* promise. `false` returns right after the monitor `cont`, leaving readiness
|
|
668
|
+
* to the caller. A wait that never reaches REST is a genuine `BOOT_TIMEOUT`. */
|
|
669
|
+
wait?: boolean;
|
|
670
|
+
/** Extra milliseconds added to the computed boot timeout — the same knob as
|
|
671
|
+
* `StartOptions.timeoutExtra`, for slow hosts where the default underestimates
|
|
672
|
+
* the resume-to-ready time. */
|
|
673
|
+
timeoutExtra?: number;
|
|
674
|
+
}
|
|
675
|
+
|
|
634
676
|
/**
|
|
635
677
|
* Runtime handle for a CHR virtual machine.
|
|
636
678
|
*
|
|
@@ -640,7 +682,7 @@ export interface ShutdownResult {
|
|
|
640
682
|
*
|
|
641
683
|
* After `QuickCHR.start()` resolves, the instance is REST-ready — all requested
|
|
642
684
|
* provisioning (packages, license, device-mode, user creation) has completed —
|
|
643
|
-
* unless it was started with `wait: false`, or is a RouterOS 6 guest (no REST:
|
|
685
|
+
* unless it was started with `wait: false` or `frozen: true`, or is a RouterOS 6 guest (no REST:
|
|
644
686
|
* `rest()` and REST `exec()` throw `REST_UNAVAILABLE`).
|
|
645
687
|
*/
|
|
646
688
|
export interface ChrInstance {
|
|
@@ -712,6 +754,11 @@ export interface ChrInstance {
|
|
|
712
754
|
/** Request /system/shutdown once over REST, wait for QEMU exit, then hard-stop
|
|
713
755
|
* if needed. Reports forced fallback explicitly; preserves disks and credentials. */
|
|
714
756
|
shutdown(options?: ShutdownOptions): Promise<ShutdownResult>;
|
|
757
|
+
/** Resume a machine started with `frozen: true` (`start --frozen`): one monitor
|
|
758
|
+
* `cont`, then REST-ready by default. Idempotent — when the guest is already
|
|
759
|
+
* running (e.g. gdb continued it) this just clears the frozen marker. Throws
|
|
760
|
+
* `MACHINE_STOPPED` when QEMU is gone. */
|
|
761
|
+
continue(options?: ContinueOptions): Promise<void>;
|
|
715
762
|
remove(): Promise<void>;
|
|
716
763
|
clean(): Promise<void>;
|
|
717
764
|
/** Stop and permanently delete this instance (disk + state). Equivalent to stop() then remove(). */
|