@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/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);
@@ -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
- // Update persisted state
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. Two exceptions:
2112
- * `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
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
- // Bun version
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 (optional, for disk resize and extra disks)
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
@@ -386,6 +386,7 @@ export function refreshAllStatuses(): MachineState[] {
386
386
  if (m.status === "running" && !isMachineRunning(m)) {
387
387
  m.status = "stopped";
388
388
  m.pid = undefined;
389
+ m.frozen = undefined;
389
390
  saveMachine(m);
390
391
  }
391
392
  }
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(). */