@tikoci/quickchr 0.5.2 → 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/DESIGN.md CHANGED
@@ -1176,3 +1176,39 @@ before acting, and uses shared stopped-state cleanup without recursively taking
1176
1176
  the non-reentrant stop lock. It confirms QEMU exit before clearing the PID or
1177
1177
  releasing named-network membership. Lock wait, hard-stop escalation and final
1178
1178
  exit verification are separate bounded phases outside the guest grace budget.
1179
+
1180
+ ## Frozen boot + continue (#214)
1181
+
1182
+ Some lab workflows must touch the guest before RouterOS init runs. The driver:
1183
+ CHR's `:beep` speaker driver is skipped when CPUID reports a hypervisor, and
1184
+ patching the 2-byte gate in guest RAM via gdbstub before sys2 init unlocks
1185
+ PC-speaker capture for acoustic testing (verified on CHR 7.25, QEMU 11.1.2).
1186
+ `start --frozen` boots held at reset (`-S`) with a gdbstub; `quickchr continue`
1187
+ resumes. The RAM patch itself stays in the harness, not quickchr.
1188
+
1189
+ The gdbstub is a unix socket (`-gdb unix:<machineDir>/gdb.sock,server=on,
1190
+ wait=off`), not `-s` (TCP :1234): no port bookkeeping, no parallel-VM
1191
+ collisions. `wait=off` is required so the spawn never blocks waiting for a
1192
+ client. Measured: `info status` reads `paused (prelaunch)`; gdb attaches with
1193
+ the CPU at the reset vector `0xfff0`. POSIX-first by scope — a Windows TCP
1194
+ fallback (e.g. `portBase+9`) is parked as a follow-up, and the integration test
1195
+ is gated off win32 until it lands.
1196
+
1197
+ `frozen` is per-boot intent, like `--no-wait`, not persisted config: stopping
1198
+ clears the marker and a later plain `start` boots normally. `status` stays
1199
+ `"running"` while frozen — without a third status value — so `isMachineRunning()`,
1200
+ `stop`/`remove` (host-side SIGTERM works on frozen QEMU unchanged), and the
1201
+ `--all` filters keep working untouched. A never-continued frozen machine can
1202
+ never produce `BOOT_TIMEOUT`, by construction: the boot wait is skipped
1203
+ entirely, and `start` returns after the spawn early-exit check.
1204
+
1205
+ `continue` is one existing primitive — `monitorCommand("cont")` — plus the
1206
+ promise `start` makes: REST-ready by default, `--no-wait` to return
1207
+ immediately, `--timeout-extra` extending the resume wait like `start`. It
1208
+ checks `info status` first, so a guest gdb already resumed just clears the
1209
+ marker (idempotent); a QEMU-level `cont` error throws `PROCESS_FAILED` before
1210
+ the marker is touched (same `/^error/i` contract as savevm/loadvm); a dead pid
1211
+ is `MACHINE_STOPPED`; a wait that never reaches REST is a genuine
1212
+ `BOOT_TIMEOUT` with `preserve-existing` cleanup. Deliberately out: no frozen-specific hint on `exec`/`get` failures,
1213
+ and no `savevm` refusal on prelaunch VMs (snapshot is already tricky business;
1214
+ QEMU accepts or errors on its own).
package/MANUAL.md CHANGED
@@ -167,6 +167,7 @@ directory, downloads the RouterOS image (cached), and writes
167
167
  | `--no-winbox` | false | Exclude WinBox port |
168
168
  | `--no-api-ssl` | false | Exclude API-SSL port |
169
169
  | `--port-base <n>` | auto from 9100 | First port of the 10-port block |
170
+ | `--qemu-arg <arg>` | — | Extra QEMU argv element, repeatable (space form only). Recorded and reused every boot. See Extra QEMU arguments below. |
170
171
 
171
172
  `--vmnet-shared` and `--vmnet-bridge <iface>` have been removed. Use
172
173
  `--add-network shared` and `--add-network bridged:<iface>`; either old spelling is
@@ -194,7 +195,9 @@ Additional flags:
194
195
  | `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
195
196
  | `--bg` / `--background` | **Deprecated, does nothing.** Background is the default, and the flag never made `start` return early — it only ever chose where the serial console went. Prints a warning; use `--no-wait` |
196
197
  | `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
197
- | `--dry-run` | Print resolved options and QEMU command, exit |
198
+ | `--dry-run` | Print the resolved start options as JSON (including `qemuArgs`/`frozen`) and exit — not the final QEMU argv |
199
+ | `--qemu-arg <arg>` | Extra QEMU argv element, repeatable (space form only). Recorded when creating; on an existing machine it replaces recorded args for this boot only. Refused with `--all`. |
200
+ | `--frozen` | Boot frozen at reset (`-S`) with a gdbstub; resume with `continue`. Refused with `--fg`, provisioning, and `--all` |
198
201
 
199
202
  After `--no-wait`, readiness is yours. centrs (0.1.8+) waits for it in one command:
200
203
 
@@ -218,6 +221,62 @@ When `--fg` is combined with provisioning options, quickchr boots
218
221
  foreground with stdio attached. This is observable as a brief stop in
219
222
  machine state.
220
223
 
224
+ **Extra QEMU arguments (`--qemu-arg`).** One argv element per occurrence,
225
+ appended verbatim (no splitting, no validation) after quickchr's own args.
226
+ Use the space form: `--qemu-arg=x` overwrites instead of accumulating, so
227
+ mixing space and `=` forms keeps only the `=` values. Values beginning with
228
+ `--` need the `--qemu-arg=<arg>` form (one element per flag).
229
+
230
+ Passthrough runs with your privileges and is replayed every boot: it can
231
+ touch any path you can (`-drive`, `-audiodev`) or open QEMU-side listeners,
232
+ so treat recorded args like config you own. A bad recorded arg fails every
233
+ start; recover with a session override (`start <name> --qemu-arg …`) and then
234
+ persist the fix with `set <name> --qemu-arg …` (or `--clear-qemu-args`) while
235
+ stopped.
236
+ Ordering is load-bearing — passthrough goes last, so a conflicting option
237
+ resolves last-wins (a duplicate `-M pc,...` merges over quickchr's `-M pc`;
238
+ measured on QEMU 11.1.2). A bad element surfaces as `SPAWN_FAILED` with the
239
+ QEMU log tail. Lab escape hatch for QEMU surface quickchr does not model —
240
+ e.g. capturing the CHR PC speaker (x86 only) to a WAV file:
241
+
242
+ ```sh
243
+ quickchr start beep-lab --version 7.25 --arch x86 \
244
+ --qemu-arg -M --qemu-arg pc,pcspk-audiodev=cap \
245
+ --qemu-arg -audiodev --qemu-arg wav,id=cap,path=$PWD/cap.wav
246
+ # cap.wav is created at spawn (0 bytes while running); QEMU flushes the
247
+ # 44-byte RIFF header on exit. Capturing actual beeps also needs the
248
+ # --frozen RAM-patch workflow below: stock CHR skips :beep under a
249
+ # hypervisor, so an unpatched guest never emits audio.
250
+ ```
251
+
252
+ #### `continue <name> [--no-wait] [--timeout-extra <s>]`
253
+
254
+ Resume a machine started with `start --frozen` (held at reset with a
255
+ gdbstub). Sends one monitor `cont`, then waits for RouterOS to become
256
+ ready — the same promise `start` makes. `--no-wait` returns right after
257
+ resuming; `--timeout-extra`/`-T` extends the resume timeout like `start`. Idempotent: when the guest is already running (e.g. gdb
258
+ continued it), this just clears the frozen marker. A machine that never
259
+ reaches readiness is a `BOOT_TIMEOUT`; it is stopped and preserved.
260
+
261
+ `--frozen` exists for harnesses that must touch the guest before RouterOS
262
+ init runs — e.g. patching guest RAM via gdb, then resuming:
263
+
264
+ ```sh
265
+ quickchr start beep-lab --version 7.25 --arch x86 --frozen
266
+ # frozen at reset; gdb at <machineDir>/gdb.sock (see 'quickchr list beep-lab')
267
+ gdb -ex "target remote /path/to/machines/beep-lab/gdb.sock" \
268
+ -ex "set *(char *)0xADDR = 0x.." -ex detach -ex quit
269
+ quickchr continue beep-lab
270
+ # resumed, REST-ready
271
+ ```
272
+
273
+ Frozen is per-boot intent, not config: `stop` clears it and a later plain
274
+ `start` boots normally. `list` marks frozen machines (`(frozen)` in the
275
+ table, a `Frozen:` line plus the gdb socket path in the detail view).
276
+ Note the vocabulary overlap: this `frozen` (QEMU held at reset) is a
277
+ different domain from QGA `fsfreeze-status` (guest filesystems frozen for
278
+ snapshots).
279
+
221
280
  #### `stop [name | --all]`
222
281
 
223
282
  Power cut: sends SIGTERM to QEMU, waits up to 5 seconds, then SIGKILL if
@@ -322,7 +381,10 @@ repeat the original command rather than to run a bare `start`.
322
381
 
323
382
  Without a name: tabular summary of every machine. With a name: detailed
324
383
  view including credentials and connection tips. `--json` for
325
- machine-readable output.
384
+ machine-readable output. A machine held by `start --frozen` shows
385
+ `(frozen)` in the table and a `Frozen:` line with its gdb socket path in
386
+ the detail view; single-machine `--json` carries `frozen` (and the recorded
387
+ `qemuArgs`) alongside the other fields.
326
388
 
327
389
  A machine whose `machine.json` cannot be read — unreadable on disk, not valid JSON, or
328
390
  valid JSON that is not usable machine state — still appears, as a row marked
@@ -449,6 +511,8 @@ flags that were asked for so it can be run as given.
449
511
  | `--device-mode <m>` | `rose`, `advanced`, `basic`, `home`, `auto`. |
450
512
  | `--device-mode-enable <f,…>` | Set one or more device-mode flags to yes. Repeatable, comma-separated. |
451
513
  | `--device-mode-disable <f,…>` | Set one or more to no. |
514
+ | `--qemu-arg <arg>` | Replace the recorded QEMU passthrough args (repeatable, space-form only). Stopped only; takes effect on the next start. |
515
+ | `--clear-qemu-args` | Drop the recorded QEMU passthrough args. Stopped only. |
452
516
 
453
517
  **Device-mode restarts the machine.** RouterOS only applies a device-mode change
454
518
  across a power cycle, and quickchr owns the power button — which is why this lives
@@ -819,7 +883,7 @@ throws `INVALID_NAME` for a name that is not a usable path segment.
819
883
  |---|---|---|
820
884
  | Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
821
885
  | Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
822
- | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown |
886
+ | Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `continue(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown, frozen resume |
823
887
  | Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
824
888
  | Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
825
889
  | Files | `upload()`, `download()` | SCP transfer to/from the CHR |
@@ -841,6 +905,7 @@ interface ChrInstance {
841
905
  waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
842
906
  stop(): Promise<void>; // power cut: SIGTERM, then SIGKILL
843
907
  shutdown(opts?: ShutdownOptions): Promise<ShutdownResult>; // /system/shutdown, bounded fallback
908
+ continue(opts?: ContinueOptions): Promise<void>; // resume a frozen start, REST-ready by default
844
909
  remove(): Promise<void>;
845
910
  clean(): Promise<void>;
846
911
  destroy(): Promise<void>; // stop + remove
@@ -927,6 +992,8 @@ redact before logging or attaching to bug reports.
927
992
  | `installDeps` | boolean | — | **Deprecated, ignored.** Never installed anything; run `quickchr doctor` for install hints |
928
993
  | `dryRun` | boolean | `false` | Print QEMU command and exit |
929
994
  | `timeoutExtra` | number | `0` | Extra **milliseconds** added to boot timeout |
995
+ | `qemuArgs` | string[] | — | Extra QEMU argv elements, one entry each, appended verbatim last. Recorded by `add()`; a `start()` value replaces stored args for that boot only |
996
+ | `frozen` | boolean | `false` | Boot frozen at reset with a gdbstub; resume with `continue()`. Refused with provisioning or `background: false` |
930
997
  | `onProgress` | `(msg) => void` | `console.log` | `[debug]` lines only when `QUICKCHR_DEBUG=1` |
931
998
 
932
999
  Provisioning fields trigger a version check at the top of `start()` /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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/flags.ts CHANGED
@@ -20,12 +20,13 @@ const CREATE_FLAGS = [
20
20
  "boot-disk-format", "boot-size", "add-disk",
21
21
  "device-mode", "device-mode-enable", "device-mode-disable",
22
22
  "add-user", "disable-admin", "secure-login",
23
+ "qemu-arg",
23
24
  ] as const;
24
25
 
25
26
  /** `start` also boots, so it owns the run-time flags on top of the create set. */
26
27
  const START_ONLY_FLAGS = [
27
28
  "all", "dry-run", "wait", "background", "bg", "fg", "foreground",
28
- "timeout-extra",
29
+ "timeout-extra", "frozen",
29
30
  "license-level", "license-account", "license-password",
30
31
  ] as const;
31
32
 
@@ -40,6 +41,7 @@ const START_ONLY_FLAGS = [
40
41
  const SET_ONLY_FLAGS = [
41
42
  "name", "license", "level", "account", "password",
42
43
  "device-mode", "device-mode-enable", "device-mode-disable",
44
+ "qemu-arg", "clear-qemu-args",
43
45
  ] as const;
44
46
 
45
47
  export const ADD_FLAGS: readonly string[] = CREATE_FLAGS;
@@ -78,6 +80,7 @@ export const VALUE_FLAGS: ReadonlySet<string> = new Set([
78
80
  "device-mode", "device-mode-enable", "device-mode-disable",
79
81
  "add-user", "timeout-extra",
80
82
  "license-level", "license-account", "license-password",
83
+ "qemu-arg",
81
84
  // exec / qga / console
82
85
  "via", "user", "password", "timeout", "path", "data", "script",
83
86
  // set --license
package/src/cli/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { StartOptions, Arch, Channel, DeviceModeOptions, ServiceName, NetworkSpecifier } from "../lib/types.ts";
7
+ import { join } from "node:path";
7
8
  import { parseNetworkSpecifier } from "../lib/network.ts";
8
9
  import { expandForwardSpec } from "../lib/forward-spec.ts";
9
10
  import { ADD_FLAGS, REMOVED_FLAGS, SET_FLAGS, START_FLAGS, VALUE_FLAGS, unknownFlags, unknownFlagMessage, valuelessFlags, valuelessFlagMessage } from "./flags.ts";
@@ -337,6 +338,9 @@ async function main() {
337
338
  case "start":
338
339
  await cmdStart(args.slice(1));
339
340
  break;
341
+ case "continue":
342
+ await cmdContinue(args.slice(1));
343
+ break;
340
344
  case "stop":
341
345
  await cmdStop(args.slice(1));
342
346
  break;
@@ -732,6 +736,7 @@ async function cmdAdd(argv: string[]) {
732
736
  bootSize: flag(flags, "boot-size"),
733
737
  extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
734
738
  extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
739
+ qemuArgs: flagList(flags, "qemu-arg").length > 0 ? flagList(flags, "qemu-arg") : undefined,
735
740
  };
736
741
 
737
742
  opts.deviceMode = deviceModeFromFlags(flags);
@@ -1362,15 +1367,16 @@ async function cmdSetup() {
1362
1367
 
1363
1368
  /** Whether the `secure-login` *setting* reaches this start. The setting is a default,
1364
1369
  * not a request, so it stays out of every start that cannot provision: a closed
1365
- * window, a `--no-wait` start (#159), and a RouterOS 6 machine (#182). Otherwise a
1366
- * user with `secure-login=true` configured could never use either. An explicit
1367
- * `--secure-login` is still passed through, and refused by name. */
1370
+ * window, a `--no-wait` start (#159), a `--frozen` start (#214), and a RouterOS 6
1371
+ * machine (#182). Otherwise a user with `secure-login=true` configured could never
1372
+ * use any of them. An explicit `--secure-login` is still passed through, and
1373
+ * refused by name. */
1368
1374
  export function secureLoginSettingApplies(
1369
1375
  flags: Record<string, string | boolean | string[]>,
1370
1376
  windowOpen: boolean,
1371
1377
  version: string | undefined,
1372
1378
  ): boolean {
1373
- return windowOpen && flags.wait !== false && !isRouterOs6(version ?? "");
1379
+ return windowOpen && flags.wait !== false && flags.frozen !== true && !isRouterOs6(version ?? "");
1374
1380
  }
1375
1381
 
1376
1382
  /** `--bg` never did what it reads as (#159): background is the default, and the flag
@@ -1482,6 +1488,7 @@ export async function cmdStart(argv: string[]) {
1482
1488
  extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
1483
1489
  dryRun: flagBool(flags, "dry-run"),
1484
1490
  timeoutExtra: await resolveTimeoutExtraMs(flags),
1491
+ qemuArgs: flagList(flags, "qemu-arg").length > 0 ? flagList(flags, "qemu-arg") : undefined,
1485
1492
  };
1486
1493
 
1487
1494
  opts.deviceMode = deviceModeFromFlags(flags);
@@ -1525,6 +1532,7 @@ export async function cmdStart(argv: string[]) {
1525
1532
  ));
1526
1533
  }
1527
1534
  opts.wait = wait;
1535
+ opts.frozen = flagBool(flags, "frozen") || undefined;
1528
1536
 
1529
1537
  // Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg.
1530
1538
  // `--bg` / `--background` are accepted and change nothing (see warnDeprecatedBackgroundFlag).
@@ -1541,8 +1549,9 @@ export async function cmdStart(argv: string[]) {
1541
1549
  }
1542
1550
 
1543
1551
  // --fg on already-running machine: attach to its serial console.
1544
- // `--fg --no-wait` skips both foreground blocks so start() refuses the pair before any I/O.
1545
- if (!opts.background && opts.wait !== false && opts.name) {
1552
+ // `--fg --no-wait` and `--fg --frozen` skip both foreground blocks so start()
1553
+ // refuses the pair before any I/O.
1554
+ if (!opts.background && opts.wait !== false && !opts.frozen && opts.name) {
1546
1555
  const runningMachine = QuickCHR.list().find((m) => m.name === opts.name && m.status === "running");
1547
1556
  if (runningMachine) {
1548
1557
  await attachSerial(runningMachine.name, runningMachine.machineDir);
@@ -1550,7 +1559,7 @@ export async function cmdStart(argv: string[]) {
1550
1559
  }
1551
1560
  }
1552
1561
 
1553
- if (!opts.background && opts.wait !== false) {
1562
+ if (!opts.background && opts.wait !== false && !opts.frozen) {
1554
1563
  const wantsDeviceMode = !!opts.deviceMode && !["skip", "none", "off", "disabled"].includes((opts.deviceMode.mode ?? "auto").toLowerCase());
1555
1564
  const hasProv = !!(
1556
1565
  opts.installAllPackages ||
@@ -1571,7 +1580,12 @@ export async function cmdStart(argv: string[]) {
1571
1580
 
1572
1581
  const instance = await QuickCHR.start(opts);
1573
1582
 
1574
- if (!opts.background) {
1583
+ // The marker describes the machine, not the request: --frozen on an
1584
+ // already-running normal machine returns that handle unfrozen.
1585
+ if (instance.state.frozen) {
1586
+ console.log(`${statusIcon("running")} ${bold(instance.name)} frozen at reset — patch RAM, then ${bold(`quickchr continue ${instance.name}`)}`);
1587
+ console.log(` GDB: ${dim(join(instance.state.machineDir, "gdb.sock"))}`);
1588
+ } else if (!opts.background) {
1575
1589
  console.log(`\n${bold(instance.name)} session ended`);
1576
1590
  console.log(` ${dim("Tip: resume session")} quickchr start ${instance.name} --fg`);
1577
1591
  console.log(` ${dim("Tip: run background")} quickchr start ${instance.name}`);
@@ -1597,6 +1611,39 @@ export async function cmdStart(argv: string[]) {
1597
1611
  }
1598
1612
  }
1599
1613
 
1614
+ export async function cmdContinue(argv: string[]) {
1615
+ const { flags, positional } = parseFlags(argv);
1616
+ applyTimeoutExtraShortFlag(argv, flags, positional);
1617
+ const invalid = unknownFlags(flags, ["wait", "timeout-extra"]);
1618
+ if (invalid.length) throw new QuickCHRError("INVALID_ARGUMENT", "continue accepts only <name>, --no-wait and --timeout-extra <s>");
1619
+ const name = positional[0];
1620
+ if (!name || positional.length !== 1) throw new QuickCHRError("INVALID_ARGUMENT", "Usage: quickchr continue <name> [--no-wait] [--timeout-extra <s>]");
1621
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1622
+ const { machineNotFoundMessage } = await import("./format.ts");
1623
+ const instance = QuickCHR.get(name);
1624
+ if (!instance) {
1625
+ console.error(machineNotFoundMessage(name));
1626
+ process.exit(1);
1627
+ }
1628
+ const wait = flags.wait !== false;
1629
+ const timeoutExtra = await resolveTimeoutExtraMs(flags);
1630
+ await instance.continue({ wait, timeoutExtra });
1631
+ const { statusIcon, bold, link, resolveDisplayCredentials, formatRestUrl, formatSshCommand } = await import("./format.ts");
1632
+ if (!wait) {
1633
+ console.log(`${statusIcon("running")} ${bold(name)} resumed — not waiting for RouterOS (--no-wait)`);
1634
+ printReadinessTip(name, instance.state.version);
1635
+ return;
1636
+ }
1637
+ const creds = await resolveDisplayCredentials(instance.state);
1638
+ console.log(`${statusIcon("running")} ${bold(name)} resumed`);
1639
+ if (isRouterOs6(instance.state.version)) {
1640
+ console.log(` API: 127.0.0.1:${instance.ports.api}`);
1641
+ } else {
1642
+ console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1643
+ }
1644
+ console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
1645
+ }
1646
+
1600
1647
  async function cmdShutdown(argv: string[]) {
1601
1648
  const { flags, positional } = parseFlags(argv);
1602
1649
  const invalid = unknownFlags(flags, ["timeout"]);
@@ -1691,6 +1738,8 @@ async function cmdList(argv: string[] = []) {
1691
1738
  networks: s.networks,
1692
1739
  ports: s.ports,
1693
1740
  pid: s.pid ?? null,
1741
+ frozen: s.frozen ?? false,
1742
+ qemuArgs: s.qemuArgs ?? [],
1694
1743
  packages: s.packages,
1695
1744
  machineDir: s.machineDir,
1696
1745
  createdAt: s.createdAt,
@@ -1706,6 +1755,10 @@ async function cmdList(argv: string[] = []) {
1706
1755
  console.log(` Network: ${formatNetworks(s.networks)}`);
1707
1756
  console.log(` Ports: ${formatPorts(s.ports)}`);
1708
1757
  console.log(` Status: ${s.status}${s.pid ? ` (PID ${s.pid})` : ""}`);
1758
+ if (s.frozen) {
1759
+ console.log(` Frozen: yes — awaiting quickchr continue`);
1760
+ console.log(` GDB: ${dim(join(s.machineDir, "gdb.sock"))}`);
1761
+ }
1709
1762
  console.log(` Created: ${new Date(s.createdAt).toLocaleString()}`);
1710
1763
  if (s.lastStartedAt) console.log(` Started: ${new Date(s.lastStartedAt).toLocaleString()}`);
1711
1764
  if (s.packages.length > 0) console.log(` Packages: ${s.packages.join(", ")}`);
@@ -1715,6 +1768,7 @@ async function cmdList(argv: string[] = []) {
1715
1768
  if (s.status === "running") {
1716
1769
  const creds = await resolveDisplayCredentials(s);
1717
1770
  console.log();
1771
+ if (s.frozen) console.log(` ${dim(`(endpoints answer after 'quickchr continue ${s.name}')`)}`);
1718
1772
  console.log(` REST: ${link(formatRestUrl(instance.ports.http, creds.user, creds.password))}`);
1719
1773
  console.log(` WinBox: 127.0.0.1:${instance.ports.winbox}`);
1720
1774
  console.log(` SSH: ${formatSshCommand(creds.user, instance.sshPort)}`);
@@ -1760,7 +1814,7 @@ async function cmdList(argv: string[] = []) {
1760
1814
  const headers = ["", "Name", "Version", "Arch", "Network", "Ports", "PID"];
1761
1815
  const rows = machines.map((m) => [
1762
1816
  statusIcon(m.status),
1763
- m.name,
1817
+ m.frozen ? `${m.name} (frozen)` : m.name,
1764
1818
  m.version,
1765
1819
  m.arch,
1766
1820
  formatNetworks(m.networks ?? []),
@@ -2149,7 +2203,7 @@ async function cmdSet(argv: string[]) {
2149
2203
  // --license` printed a usage error while `quickchr license --name lab` worked.
2150
2204
  const name = positional[0] ?? flag(flags, "name");
2151
2205
 
2152
- const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>]";
2206
+ const usage = "Usage: quickchr set <name> [--license [--level=p1|p10|unlimited]] [--device-mode <mode>] [--device-mode-enable <features>] [--device-mode-disable <features>] [--qemu-arg <arg>] [--clear-qemu-args]";
2153
2207
  if (!name) {
2154
2208
  console.error(usage);
2155
2209
  process.exit(1);
@@ -2171,12 +2225,22 @@ async function cmdSet(argv: string[]) {
2171
2225
 
2172
2226
  const wantsLicense = flagBool(flags, "license");
2173
2227
  const deviceMode = deviceModeFromFlags(flags);
2228
+ const qemuArgs = flagList(flags, "qemu-arg");
2229
+ const clearQemuArgs = flagBool(flags, "clear-qemu-args");
2230
+ const wantsQemuArgs = qemuArgs.length > 0 || clearQemuArgs;
2231
+ if (qemuArgs.length > 0 && clearQemuArgs) {
2232
+ throw new QuickCHRError("INVALID_ARGUMENT", "--qemu-arg and --clear-qemu-args conflict: --qemu-arg already replaces the recorded args wholesale");
2233
+ }
2174
2234
 
2175
- if (!wantsLicense && !deviceMode) {
2176
- console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable");
2235
+ if (!wantsLicense && !deviceMode && !wantsQemuArgs) {
2236
+ console.error("Nothing to set. Available flags: --license, --device-mode, --device-mode-enable, --device-mode-disable, --qemu-arg, --clear-qemu-args");
2177
2237
  process.exit(1);
2178
2238
  }
2179
2239
 
2240
+ // Recorded args first: they need a stopped machine, and failing fast here
2241
+ // keeps a running-machine refusal from racing a device-mode power cycle.
2242
+ if (wantsQemuArgs) await applyQemuArgsChange(name, qemuArgs, clearQemuArgs);
2243
+
2180
2244
  // Device-mode first: it power-cycles the guest, and a license applied beforehand
2181
2245
  // would be read back across a reboot it did not expect.
2182
2246
  if (deviceMode) await applyDeviceModeChange(name, deviceMode);
@@ -2186,6 +2250,43 @@ async function cmdSet(argv: string[]) {
2186
2250
  }
2187
2251
  }
2188
2252
 
2253
+ /** `set <name> --qemu-arg…` — the recovery route for recorded passthrough.
2254
+ *
2255
+ * Boot config, not provisioning: it takes effect on the next start, so the
2256
+ * machine must be stopped — accepting it on a running machine would promise a
2257
+ * change that cannot happen until reboot. Replaces the recorded args wholesale
2258
+ * (one element per flag, like `add`); `--clear-qemu-args` drops them. A bad
2259
+ * recorded arg otherwise fails every start until `machine.json` is hand-edited;
2260
+ * this is the supported way back. */
2261
+ async function applyQemuArgsChange(name: string, qemuArgs: string[], clear: boolean) {
2262
+ const { QuickCHR } = await import("../lib/quickchr.ts");
2263
+ const { bold, machineNotFoundMessage } = await import("./format.ts");
2264
+ const { loadMachine, saveMachine } = await import("../lib/state.ts");
2265
+
2266
+ const instance = QuickCHR.get(name);
2267
+ if (!instance) {
2268
+ console.error(machineNotFoundMessage(name));
2269
+ process.exit(1);
2270
+ }
2271
+ if (instance.state.status === "running") {
2272
+ console.error(`Machine "${name}" is running — stop it first: qemuArgs apply at boot, so 'set' only records them on a stopped machine.`);
2273
+ process.exit(1);
2274
+ }
2275
+ const current = loadMachine(name);
2276
+ if (!current) {
2277
+ console.error(machineNotFoundMessage(name));
2278
+ process.exit(1);
2279
+ }
2280
+ current.qemuArgs = clear ? undefined : [...qemuArgs];
2281
+ saveMachine(current);
2282
+ instance.state.qemuArgs = current.qemuArgs;
2283
+ if (clear) {
2284
+ console.log(`Cleared recorded QEMU args on ${bold(name)} — the next start boots without passthrough.`);
2285
+ } else {
2286
+ console.log(`Recorded QEMU args on ${bold(name)}: ${qemuArgs.join(" ")}`);
2287
+ }
2288
+ }
2289
+
2189
2290
  /** `set <name> --device-mode…` — the post-boot route the refusal names.
2190
2291
  *
2191
2292
  * Device-mode needs a **power cycle**: RouterOS will not apply it otherwise, and
@@ -3179,6 +3280,7 @@ Commands:
3179
3280
  setup Interactive setup wizard (TTY only)
3180
3281
  add [options] Create a new CHR machine (without starting)
3181
3282
  start [<name>|options] Start or restart a CHR instance
3283
+ continue <name> Resume a machine started with --frozen
3182
3284
  stop [<name>|--all] Power cut — print list if no name
3183
3285
  shutdown <name> Request guest shutdown, then bounded hard-stop fallback
3184
3286
  list [<name>] [--json] List all instances, or detail for one
@@ -3269,6 +3371,10 @@ Options:
3269
3371
  --add-network <spec> Add a network NIC (repeatable). Specs: user, shared, bridged:<if>,
3270
3372
  socket::<name>, tap:<if>. Default: single user NIC.
3271
3373
  --no-network Start with no NICs (headless)
3374
+ --qemu-arg <arg> Extra QEMU argv element, appended verbatim after quickchr's
3375
+ own args (repeatable, space-form only: one element per
3376
+ flag). Recorded here and reused every boot; a start-time
3377
+ value replaces it for that boot only.
3272
3378
 
3273
3379
  Provisioning flags apply on the machine's first boot — whether they are given
3274
3380
  here or on that first 'start'. After the guest has booted, a flag the machine already
@@ -3346,6 +3452,8 @@ Options:
3346
3452
  (plus the deprecated no-op --bg / --background); any other
3347
3453
  option is refused rather than ignored
3348
3454
  --no-wait Return once QEMU is running; do not wait for RouterOS (not with --fg)
3455
+ --frozen Boot frozen at reset (-S) with a gdbstub on <machineDir>/gdb.sock;
3456
+ resume with 'quickchr continue' (not with --fg, --all or provisioning)
3349
3457
  --fg / --foreground Run in foreground — serial console on stdio
3350
3458
  --bg / --background Deprecated, does nothing: background is already the default
3351
3459
  --version <ver> RouterOS version (e.g., 7.22.1)
@@ -3386,6 +3494,10 @@ Options:
3386
3494
  --no-device-mode Skip device-mode provisioning entirely
3387
3495
  --timeout-extra <s>, -T <s> Add extra seconds to the auto-computed boot timeout
3388
3496
  --dry-run Print what would run without starting
3497
+ --qemu-arg <arg> Extra QEMU argv element, appended verbatim after quickchr's
3498
+ own args (repeatable, space-form only: one element per
3499
+ flag). Recorded when creating; on an existing machine it
3500
+ replaces recorded args for this boot only.
3389
3501
 
3390
3502
  Provisioning flags (packages, login, device-mode, license) apply only on a machine's
3391
3503
  first boot. On a machine that has booted, a flag the machine already has (same
@@ -3394,6 +3506,22 @@ naming where the change can still be made — often 'quickchr set <name>'. Only
3394
3506
  'quickchr clean <name>' if you mean it: it resets the disk to factory, erasing the
3395
3507
  guest's configuration, and reopens the first-boot window.`);
3396
3508
  break;
3509
+ case "continue":
3510
+ console.log(`quickchr continue <name> [--no-wait] [--timeout-extra <s>]
3511
+
3512
+ Resume a machine started with 'start --frozen' (held at reset with a gdbstub
3513
+ on <machineDir>/gdb.sock). Sends one monitor 'cont', then waits for RouterOS
3514
+ to become ready — the same promise 'start' makes.
3515
+
3516
+ <name> Name of a frozen (or otherwise paused) running machine.
3517
+ --no-wait Return right after resuming; do not wait for RouterOS.
3518
+ --timeout-extra <s>, -T <s>
3519
+ Add extra seconds to the auto-computed resume timeout.
3520
+
3521
+ Resuming is idempotent: when the guest is already running (e.g. gdb continued
3522
+ it), this just clears the frozen marker. A wait that never reaches readiness
3523
+ is a BOOT_TIMEOUT; the machine is stopped and preserved.`);
3524
+ break;
3397
3525
  case "stop":
3398
3526
  console.log(`quickchr stop [<name>] [--all]
3399
3527
 
@@ -3545,6 +3673,10 @@ Flags:
3545
3673
  --device-mode <m> Set device-mode: rose|advanced|basic|home|auto
3546
3674
  --device-mode-enable <f> Set one or more device-mode flags to yes (e.g. container)
3547
3675
  --device-mode-disable <f> Set one or more device-mode flags to no
3676
+ --qemu-arg <arg> Replace the recorded QEMU passthrough args (repeatable,
3677
+ space-form only). The machine must be stopped; takes
3678
+ effect on the next start.
3679
+ --clear-qemu-args Drop the recorded QEMU passthrough args. Stopped only.
3548
3680
 
3549
3681
  Device-mode notes:
3550
3682
  RouterOS only applies device-mode across a power cycle, so 'set' restarts the
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type {
32
32
  Channel,
33
33
  ChrInstance,
34
34
  ChrPorts,
35
+ ContinueOptions,
35
36
  CustomForward,
36
37
  Descriptor,
37
38
  DeviceModeOptions,
@@ -328,7 +328,7 @@ _quickchr() {
328
328
  cword=${D}COMP_CWORD
329
329
  }
330
330
 
331
- local commands="add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
331
+ local commands="add start continue stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -337,7 +337,7 @@ _quickchr() {
337
337
 
338
338
  local cmd="${D}{words[1]}"
339
339
  case "${D}cmd" in
340
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
340
+ start|continue|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
341
341
  local machines
342
342
  machines=$(quickchr completions --machines 2>/dev/null)
343
343
  COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
@@ -387,6 +387,7 @@ _quickchr() {
387
387
  local commands=(
388
388
  'add:Create a new CHR machine (without starting)'
389
389
  'start:Start or restart a CHR instance'
390
+ 'continue:Resume a machine started with --frozen'
390
391
  'stop:Power cut instance(s)'
391
392
  'shutdown:Request guest shutdown'
392
393
  'list:List all instances'
@@ -421,7 +422,7 @@ _quickchr() {
421
422
  args)
422
423
  local cmd="${D}{words[2]}"
423
424
  case ${D}cmd in
424
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
425
+ start|continue|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
425
426
  local machines
426
427
  machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
427
428
  _describe 'machine' machines
@@ -447,7 +448,7 @@ _quickchr() {
447
448
  _describe 'cache subcommand' subcommands
448
449
  ;;
449
450
  help)
450
- local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
451
+ local commands=(add start continue stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
451
452
  _describe 'command' commands
452
453
  ;;
453
454
  esac
@@ -463,7 +464,7 @@ function generateFishCompletion(): string {
463
464
  return `# quickchr fish completion
464
465
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
465
466
 
466
- set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
467
+ set -l quickchr_commands add start continue stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
467
468
 
468
469
  # Disable file completions globally for quickchr
469
470
  complete -c quickchr -f
@@ -473,6 +474,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
473
474
  -a "add" -d "Create a new CHR machine"
474
475
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
475
476
  -a "start" -d "Start or restart a CHR instance"
477
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
478
+ -a "continue" -d "Resume a machine started with --frozen"
476
479
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
477
480
  -a "stop" -d "Power cut instance(s)"
478
481
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -528,7 +531,7 @@ complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
528
531
  -d "machine"
529
532
 
530
533
  # Machine name completions for commands that take any machine
531
- complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
534
+ complete -c quickchr -n "__fish_seen_subcommand_from start continue remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
532
535
  -a "(quickchr completions --machines 2>/dev/null)" \\
533
536
  -d "machine"
534
537
 
package/src/lib/lock.ts CHANGED
@@ -61,6 +61,7 @@ export type LockOperation =
61
61
  | "foreground"
62
62
  | "stop"
63
63
  | "shutdown"
64
+ | "continue"
64
65
  | "clean"
65
66
  | "remove"
66
67
  | "device-mode"
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,
@@ -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
 
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(). */