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