@tikoci/quickchr 0.4.8 → 0.5.0

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.
@@ -20,14 +20,15 @@ import type {
20
20
  NetworkTopologyEntry,
21
21
  PlatformInfo,
22
22
  PortMapping,
23
+ ProvisioningStep,
23
24
  QgaCommand,
24
25
  ServiceEndpoint,
25
26
  SnapshotInfo,
26
27
  SshServiceEndpoint,
27
28
  StartOptions,
28
29
  } from "./types.ts";
29
- import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP } from "./types.ts";
30
- import { assertValidResourceName, assertPathSafeName } from "./names.ts";
30
+ import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP, PORTS_PER_BLOCK } from "./types.ts";
31
+ import { assertValidResourceName, assertPathSafeName, shellQuote } from "./names.ts";
31
32
  import packageJson from "../../package.json";
32
33
  import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
33
34
  import {
@@ -35,6 +36,9 @@ import {
35
36
  isValidVersion,
36
37
  generateMachineName,
37
38
  assertProvisioningSupportedVersion,
39
+ assertRestAvailable,
40
+ assertRouterOs6Arch,
41
+ isRouterOs6,
38
42
  PROVISIONING_FEATURE_LABEL,
39
43
  } from "./versions.ts";
40
44
  import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
@@ -61,16 +65,17 @@ import {
61
65
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
62
66
  import { autoPruneIfOverCap } from "./cache.ts";
63
67
  import { resolveSetting } from "./settings.ts";
64
- import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
68
+ import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
65
69
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
66
70
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
67
- import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
71
+ import { captureBootFailure, newBootProbeStats, preserveOnFailure, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
68
72
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
69
73
  import { provision } from "./provision.ts";
70
74
  import { renewLicense, getLicenseInfo } from "./license.ts";
71
75
  import { resolveAuth, resolveCreds } from "./auth.ts";
72
76
  import { scpPush, scpPull } from "./scp.ts";
73
77
  import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
78
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownGuest, validateShutdownTimeout } from "./shutdown.ts";
74
79
  import { restExecute } from "./exec.ts";
75
80
  import { qgaExec } from "./qga.ts";
76
81
  import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
@@ -78,6 +83,8 @@ import { restRequest, restGet, restPost } from "./rest.ts";
78
83
  import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
79
84
  import { createLogger, type ProgressLogger } from "./log.ts";
80
85
  import {
86
+ describeDeviceModeChange,
87
+ FACTORY_AUTH_HEADER,
81
88
  formatDeviceModeSelection,
82
89
  readDeviceMode,
83
90
  resolveDeviceModeOptions,
@@ -86,11 +93,19 @@ import {
86
93
  verifyDeviceMode,
87
94
  waitForDeviceModeApi,
88
95
  } from "./device-mode.ts";
96
+ import {
97
+ appliedDeviceModeRecord,
98
+ assertProvisioningWindow,
99
+ hasProvisioningRequest,
100
+ isProvisioningWindowOpen,
101
+ pendingProvisioningFor,
102
+ } from "./provisioning-window.ts";
89
103
  import type { LicenseOptions } from "./types.ts";
90
104
  import type { GuestExec } from "./guest-snapshot.ts";
91
105
  import { toChrPorts } from "./network.ts";
92
106
  import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
93
- import { existsSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
107
+ import { existsSync, rmSync, copyFileSync } from "node:fs";
108
+ import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
94
109
  import { join } from "node:path";
95
110
 
96
111
  // --- Architecture-aware defaults ---
@@ -140,6 +155,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
140
155
  return opts;
141
156
  }
142
157
 
158
+ /** `version` and `channel` both name the release, so a caller passing both has asked
159
+ * for two things and gets one: `version` wins. Say so rather than drop `channel` in
160
+ * silence — the TSDoc used to call them "mutually exclusive" while nothing enforced
161
+ * it (#178). A warning, not a throw, matching the channel-name-as-version case. */
162
+ function warnVersionWithChannel(opts: StartOptions, logger: ProgressLogger): void {
163
+ if (opts.version && opts.channel) {
164
+ logger.warn(`Both version "${opts.version}" and channel "${opts.channel}" were given — using version ${opts.version}; channel is ignored.`);
165
+ }
166
+ }
167
+
143
168
  function listProvisioningMutations(opts: {
144
169
  installAllPackages?: boolean;
145
170
  packages?: string[];
@@ -185,6 +210,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
185
210
  return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
186
211
  }
187
212
 
213
+ /** `wait: false` returns before the guest has booted, and provisioning needs a booted
214
+ * guest — so the pair is refused by name rather than the provisioning silently
215
+ * skipped (#159). `machine` is set when the provisioning is first-boot intent that
216
+ * `add()` recorded, which the caller may not have typed on this command line. */
217
+ function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
218
+ if (opts.wait !== false) return;
219
+ const source = machine
220
+ ? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
221
+ : `provisioning was requested (${operation})`;
222
+ throw new QuickCHRError(
223
+ "INVALID_ARGUMENT",
224
+ `--no-wait (wait: false) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so start has to wait for it. ` +
225
+ "Drop --no-wait for this first boot — later starts can use it.",
226
+ );
227
+ }
228
+
188
229
  // --- Socket registry lifecycle helpers ---
189
230
 
190
231
  function getSocketNamedNetworks(state: MachineState): string[] {
@@ -316,6 +357,193 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
316
357
  return null;
317
358
  }
318
359
 
360
+ /** Put a machine's disks back to the factory image and drop the credential material
361
+ * that authenticated to the erased guest. Machine state is the caller's to update:
362
+ * `clean()` clears the guest facts and keeps intent, a provisioning rollback restores
363
+ * the record it had before the failed run. QEMU must already be stopped. */
364
+ async function resetGuestToFactory(state: MachineState): Promise<void> {
365
+ // NOTE: this deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
366
+ // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
367
+ // disk), not OS state; wiping it forces a full device scan (~480s) on the next
368
+ // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
369
+ // anchor: the "clean() resets disk to factory defaults" arm64 integration test
370
+ // (120s reboot wait would blow past 480s if the wipe were reintroduced).
371
+ // Re-copy fresh image from cache
372
+ const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
373
+ if (!existsSync(imgPath)) {
374
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
375
+ }
376
+ const destPath = join(state.machineDir, "disk.img");
377
+ copyFileSync(imgPath, destPath);
378
+
379
+ // Clean up disk files (boot.qcow2, extra disks)
380
+ cleanDiskFiles(state.machineDir);
381
+
382
+ // Re-prepare disks if the machine had disk customizations
383
+ await ensureConfiguredDisks(
384
+ state.machineDir,
385
+ state.bootSize,
386
+ state.extraDisks,
387
+ state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
388
+ );
389
+
390
+ deleteInstanceCredentials(state.name);
391
+ // The managed keypair authenticated to the account factory reset erased,
392
+ // so it is dead credential material sitting in the machine dir. Removing
393
+ // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
394
+ // existing file exits 1 with "already exists" rather than overwriting
395
+ // (verified locally), so a leftover key would break `installSshKey()`.
396
+ rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
397
+ }
398
+
399
+ /** Options every machine-lock acquisition passes: the pre-0.5 lock to honour, and the
400
+ * noun for `MACHINE_LOCKED`. */
401
+ function machineLockOptions(name: string, machineDir: string): { legacyPath: string; subject: string } {
402
+ return { legacyPath: legacyMachineLockPath(machineDir), subject: `Machine "${name}"` };
403
+ }
404
+
405
+ /** The machine lock outlives its machine (see lock.ts), so a caller that has just
406
+ * taken it must check that the machine it was asked about is still there, and is not
407
+ * another machine created under the same name while it waited. Acting on the old
408
+ * snapshot would mean stopping a stale pid. */
409
+ function assertSameMachine(state: MachineState): void {
410
+ const fresh = loadMachine(state.name);
411
+ if (!fresh || fresh.createdAt !== state.createdAt) {
412
+ throw new QuickCHRError(
413
+ "MACHINE_NOT_FOUND",
414
+ `Machine "${state.name}" was removed${fresh ? " and created again" : ""} while this operation waited for it.`,
415
+ );
416
+ }
417
+ }
418
+
419
+ /** How long `stop`/`clean`/`remove` wait for an operation already holding the machine
420
+ * lock. The holder is almost always a start or relaunch waiting for boot, so the wait
421
+ * is that boot budget: a `stop` issued during a `start` runs after it rather than
422
+ * failing, and a holder that never finishes still cannot block it forever (#181). */
423
+ function lifecycleLockWaitMs(state: MachineState): number {
424
+ const withPackages = !!state.installAllPackages || (state.packages?.length ?? 0) > 0;
425
+ return defaultBootTimeout(state.arch, withPackages, state.lastAccel);
426
+ }
427
+
428
+ /** Run a lifecycle operation under the machine's lock, waiting for a current holder.
429
+ * State is re-read once the lock is held: a handle's snapshot predates whatever the
430
+ * holder just did, and `state.pid` read from it may name a process that has exited or
431
+ * one the OS has since reused (#180, #181). */
432
+ async function withMachineLock<T>(state: MachineState, op: LockOperation, fn: () => Promise<T>): Promise<T> {
433
+ const lock = await acquireLock(machineLockPath(state.machineDir), op, {
434
+ ...machineLockOptions(state.name, state.machineDir),
435
+ waitMs: lifecycleLockWaitMs(state),
436
+ onWait: (holder) => createLogger().warn(
437
+ `Machine "${state.name}" is busy with \`${holder.op}\` (pid ${holder.pid}) — waiting for it to finish before \`${op}\`.`,
438
+ ),
439
+ });
440
+ try {
441
+ assertSameMachine(state);
442
+ refreshMachineState(state);
443
+ return await fn();
444
+ } finally {
445
+ lock.release();
446
+ }
447
+ }
448
+
449
+ /** The body of `stop()`, for a caller that already holds the machine lock. The lock is
450
+ * not reentrant, so code inside a locked start or relaunch calls this, never
451
+ * `instance.stop()`. */
452
+ async function stopUnderLock(state: MachineState): Promise<void> {
453
+ if (state.pid) {
454
+ await stopQemu(state.pid);
455
+ }
456
+ finalizeStoppedState(state);
457
+ }
458
+
459
+ /** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
460
+ function finalizeStoppedState(state: MachineState): void {
461
+ unregisterSocketMembers(state);
462
+ // Update persisted state
463
+ const current = loadMachine(state.name);
464
+ if (current) {
465
+ current.status = "stopped";
466
+ current.pid = undefined;
467
+ saveMachine(current);
468
+ }
469
+ state.status = "stopped";
470
+ state.pid = undefined;
471
+ }
472
+
473
+ /** The body of `remove()`, for a caller that already holds the machine lock. The lock
474
+ * file goes with the directory. */
475
+ async function removeUnderLock(state: MachineState): Promise<void> {
476
+ if (state.pid && isMachineRunning(state)) {
477
+ await stopQemu(state.pid);
478
+ }
479
+ unregisterSocketMembers(state);
480
+ // Clean up stored instance credentials
481
+ deleteInstanceCredentials(state.name);
482
+ removeState(state.name);
483
+ }
484
+
485
+ /** The body of `clean()`, for a caller that already holds the machine lock. */
486
+ async function cleanUnderLock(state: MachineState): Promise<void> {
487
+ if (state.pid && isMachineRunning(state)) {
488
+ await stopQemu(state.pid);
489
+ }
490
+ // Same invariant as stop()/remove(): a machine that is not running holds no
491
+ // endpoint. clean() stops QEMU directly rather than through stop(), so without
492
+ // this a cleaned machine keeps a pair link occupied and blocks its removal.
493
+ unregisterSocketMembers(state);
494
+ await resetGuestToFactory(state);
495
+
496
+ // Every guest-side credential went with the old disk, so the credential
497
+ // facts in machine.json now describe a machine that no longer exists.
498
+ // Left in place, `resolveAuth()` /
499
+ // `resolveCreds()` prefer `state.user` and authenticate every REST call,
500
+ // exec, and SCP as a deleted user — the confound #79 removes here.
501
+ // Clearing them restores the documented fallback: factory `admin` with an
502
+ // empty password, which is what a fresh CHR image actually answers to
503
+ // (anchored by the "clean() resets disk to factory defaults" integration
504
+ // test). `disableAdmin` goes with them because it is read as a live fact
505
+ // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
506
+ // image has admin enabled again. `licenseLevel` goes with them for the same
507
+ // reason: it is a read-back of the license the erased disk held, not an
508
+ // intent — quickchr never persists the license *input* — so leaving it
509
+ // behind would report a level the fresh image does not have. Provisioning
510
+ // *intent* — packages, deviceMode, secureLogin — is not guest state and
511
+ // survives, and is what the next `start()` re-applies now that the window
512
+ // reopens.
513
+ // (`resetGuestToFactory()` has already dropped the secret-store entry and the
514
+ // managed keypair, which describe the same erased account.)
515
+
516
+ // Update state. `provisioning` and `lastStartedAt` go with the credentials:
517
+ // both describe a guest that no longer exists, and together they are what
518
+ // closes the provisioning window. Leaving them behind is what stopped a cleaned
519
+ // machine from ever being provisioned again — the disk was factory-fresh and
520
+ // `start()` still refused to provision it (#176). `cleanedAt` keeps the
521
+ // forensic clue that the timestamp carried.
522
+ const cleanedAt = new Date().toISOString();
523
+ const current = loadMachine(state.name);
524
+ if (current) {
525
+ current.status = "stopped";
526
+ current.pid = undefined;
527
+ current.user = undefined;
528
+ current.managedSshKey = undefined;
529
+ current.disableAdmin = undefined;
530
+ current.provisioning = undefined;
531
+ current.lastStartedAt = undefined;
532
+ current.licenseLevel = undefined;
533
+ current.cleanedAt = cleanedAt;
534
+ saveMachine(current);
535
+ }
536
+ state.status = "stopped";
537
+ state.pid = undefined;
538
+ state.user = undefined;
539
+ state.managedSshKey = undefined;
540
+ state.disableAdmin = undefined;
541
+ state.provisioning = undefined;
542
+ state.lastStartedAt = undefined;
543
+ state.licenseLevel = undefined;
544
+ state.cleanedAt = cleanedAt;
545
+ }
546
+
319
547
  /** Create a ChrInstance handle from persisted MachineState. */
320
548
  function createInstance(state: MachineState): ChrInstance {
321
549
  const ports = toChrPorts(state.ports);
@@ -404,14 +632,16 @@ function createInstance(state: MachineState): ChrInstance {
404
632
  // keeps the excludePorts fix: excluding "http" while keeping "https" now
405
633
  // resolves rest-api onto the surviving secure port instead of a port that
406
634
  // doesn't exist.
407
- const restApi = buildHttpService(
408
- state.ports.https,
409
- state.ports.http,
410
- "rest-api",
411
- { secure: "https", plain: "http" },
412
- { username: creds.user, password: creds.password, basic, header: auth.header },
413
- "/rest",
414
- );
635
+ const restApi: ServiceEndpoint = isRouterOs6(state.version)
636
+ ? { available: false, unavailableReason: `RouterOS ${state.version} has no REST API — use native-api` }
637
+ : buildHttpService(
638
+ state.ports.https,
639
+ state.ports.http,
640
+ "rest-api",
641
+ { secure: "https", plain: "http" },
642
+ { username: creds.user, password: creds.password, basic, header: auth.header },
643
+ "/rest",
644
+ );
415
645
  const nativeApi = buildHttpService(
416
646
  state.ports["api-ssl"],
417
647
  state.ports.api,
@@ -498,11 +728,11 @@ function createInstance(state: MachineState): ChrInstance {
498
728
  portBase: state.portBase,
499
729
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
500
730
  hostGatewayIp: HOST_GATEWAY_IP,
501
- // Deprecated alias for hostGatewayIp (#26) — same value, removed no earlier
502
- // than the next minor.
503
- tzspGatewayIp: HOST_GATEWAY_IP,
504
731
 
505
732
  async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
733
+ if (readinessProbeFor(state.version) === "api") {
734
+ return waitForApiReady(ports.api, timeoutMs ?? 120_000, resolveCreds(state), stats);
735
+ }
506
736
  // Use resolved credentials so waitForBoot can validate the response body
507
737
  // on authenticated machines (post-provisioning, post-install reboots).
508
738
  const auth = resolveAuth(state);
@@ -523,101 +753,32 @@ function createInstance(state: MachineState): ChrInstance {
523
753
  },
524
754
 
525
755
  async stop(): Promise<void> {
526
- if (state.pid) {
527
- await stopQemu(state.pid);
528
- }
529
- unregisterSocketMembers(state);
530
- // Update persisted state
531
- const current = loadMachine(state.name);
532
- if (current) {
533
- current.status = "stopped";
534
- current.pid = undefined;
535
- saveMachine(current);
536
- }
537
- state.status = "stopped";
538
- state.pid = undefined;
756
+ await withMachineLock(state, "stop", () => stopUnderLock(state));
757
+ },
758
+
759
+ async shutdown(options) {
760
+ const timeoutMs = validateShutdownTimeout(options?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
761
+ return withMachineLock(state, "shutdown", async () => {
762
+ if (!state.pid || !isMachineRunning(state)) {
763
+ finalizeStoppedState(state);
764
+ return { outcome: "already-stopped" };
765
+ }
766
+ const pid = state.pid;
767
+ const result = await shutdownGuest(timeoutMs, async (requestTimeoutMs) => {
768
+ // Rebuild after the locked refresh: a waiting handle may predate a relaunch.
769
+ return createInstance(state).exec("/system/shutdown", { via: "rest", timeout: requestTimeoutMs });
770
+ }, () => isMachineRunning(state), () => stopQemu(pid));
771
+ finalizeStoppedState(state);
772
+ return result;
773
+ });
539
774
  },
540
775
 
541
776
  async remove(): Promise<void> {
542
- if (state.pid && isMachineRunning(state)) {
543
- await stopQemu(state.pid);
544
- }
545
- unregisterSocketMembers(state);
546
- // Clean up stored instance credentials
547
- deleteInstanceCredentials(state.name);
548
- removeState(state.name);
777
+ await withMachineLock(state, "remove", () => removeUnderLock(state));
549
778
  },
550
779
 
551
780
  async clean(): Promise<void> {
552
- if (state.pid && isMachineRunning(state)) {
553
- await stopQemu(state.pid);
554
- }
555
- // Same invariant as stop()/remove(): a machine that is not running holds no
556
- // endpoint. clean() stops QEMU directly rather than through stop(), so without
557
- // this a cleaned machine keeps a pair link occupied and blocks its removal.
558
- unregisterSocketMembers(state);
559
- // NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
560
- // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
561
- // disk), not OS state; wiping it forces a full device scan (~480s) on the next
562
- // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
563
- // anchor: the "clean() resets disk to factory defaults" arm64 integration test
564
- // (120s reboot wait would blow past 480s if the wipe were reintroduced).
565
- // Re-copy fresh image from cache
566
- const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
567
- if (!existsSync(imgPath)) {
568
- throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
569
- }
570
- const destPath = join(state.machineDir, "disk.img");
571
- copyFileSync(imgPath, destPath);
572
-
573
- // Clean up disk files (boot.qcow2, extra disks)
574
- cleanDiskFiles(state.machineDir);
575
-
576
- // Re-prepare disks if the machine had disk customizations
577
- await ensureConfiguredDisks(
578
- state.machineDir,
579
- state.bootSize,
580
- state.extraDisks,
581
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
582
- );
583
-
584
- // Every guest-side credential went with the old disk, so the credential
585
- // facts in machine.json now describe a machine that no longer exists.
586
- // Nothing re-provisions them either: a post-clean() start() takes the
587
- // `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
588
- // erased account is never recreated. Left in place, `resolveAuth()` /
589
- // `resolveCreds()` prefer `state.user` and authenticate every REST call,
590
- // exec, and SCP as a deleted user — the confound #79 removes here.
591
- // Clearing them restores the documented fallback: factory `admin` with an
592
- // empty password, which is what a fresh CHR image actually answers to
593
- // (anchored by the "clean() resets disk to factory defaults" integration
594
- // test). `disableAdmin` goes with them because it is read as a live fact
595
- // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
596
- // image has admin enabled again. Provisioning *intent* — packages,
597
- // deviceMode, secureLogin — is not guest state and survives.
598
- deleteInstanceCredentials(state.name);
599
- // The managed keypair authenticated to the account factory reset erased,
600
- // so it is dead credential material sitting in the machine dir. Removing
601
- // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
602
- // existing file exits 1 with "already exists" rather than overwriting
603
- // (verified locally), so a leftover key would break `installSshKey()`.
604
- rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
605
-
606
- // Update state
607
- const current = loadMachine(state.name);
608
- if (current) {
609
- current.status = "stopped";
610
- current.pid = undefined;
611
- current.user = undefined;
612
- current.managedSshKey = undefined;
613
- current.disableAdmin = undefined;
614
- saveMachine(current);
615
- }
616
- state.status = "stopped";
617
- state.pid = undefined;
618
- state.user = undefined;
619
- state.managedSshKey = undefined;
620
- state.disableAdmin = undefined;
781
+ await withMachineLock(state, "clean", () => cleanUnderLock(state));
621
782
  },
622
783
 
623
784
  async monitor(command: string): Promise<string> {
@@ -633,6 +794,7 @@ function createInstance(state: MachineState): ChrInstance {
633
794
  },
634
795
 
635
796
  async rest(path: string, opts?: RequestInit): Promise<unknown> {
797
+ assertRestAvailable(state, "rest()");
636
798
  const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
637
799
 
638
800
  // Retry on ECONNRESET — RouterOS can transiently reset connections in the
@@ -722,6 +884,9 @@ function createInstance(state: MachineState): ChrInstance {
722
884
  `exec transport "${via}" is not yet implemented`,
723
885
  );
724
886
  }
887
+ // Before the auto→console fallback below: that fallback exists for an
888
+ // unreachable REST endpoint, not for one that cannot exist.
889
+ assertRestAvailable(state, "exec");
725
890
  const auth = resolveAuth(state, opts?.user, opts?.password);
726
891
  if (via === "rest") {
727
892
  return restExecute(restUrl, auth, command, opts);
@@ -769,15 +934,62 @@ function createInstance(state: MachineState): ChrInstance {
769
934
  const log = logger ?? createLogger();
770
935
  const resolved = resolveDeviceModeOptions(options);
771
936
  for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
772
- const launchConfig = await buildLaunchConfigFromState(state);
773
- await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log);
774
- // Persist updated device-mode in state
775
- const current = loadMachine(state.name);
776
- if (current) {
777
- current.deviceMode = options;
778
- saveMachine(current);
937
+ if (!shouldApplyDeviceMode(resolved)) {
938
+ throw new QuickCHRError(
939
+ "INVALID_ARGUMENT",
940
+ `Nothing to set: device-mode options for "${state.name}" resolved to no change ` +
941
+ "(mode=skip disables device-mode entirely; pass a mode or a feature to enable/disable).",
942
+ );
943
+ }
944
+ // The same machine lock every other relaunch takes. This one is a relaunch:
945
+ // it terminates QEMU, spawns a replacement and rewrites `machine.json`, so a
946
+ // concurrent `start` would otherwise spawn a second QEMU into the power-cycle
947
+ // window and both would persist over each other. Fail-fast like a second
948
+ // `start`: waiting would only power-cycle the machine twice.
949
+ const lock = acquireLockNow(machineLockPath(state.machineDir), "device-mode", machineLockOptions(state.name, state.machineDir));
950
+ try {
951
+ assertSameMachine(state);
952
+ // Re-read under the lock before anything reads `state`. This handle's
953
+ // snapshot is taken once, at `createInstance`, and a library consumer can
954
+ // hold it across another process's stop/start — after which `state.pid`
955
+ // names a process that has exited (a false MACHINE_STOPPED) or, worse, one
956
+ // the OS has since reused, which `hardRebootMachine()` would then
957
+ // terminate. `state.provisioning` and `state.deviceMode` go stale the same
958
+ // way, and both feed `appliedDeviceModeRecord()`.
959
+ refreshMachineState(state);
960
+ // Inside the lock: the applicability check has to hold for the operation,
961
+ // not merely at the moment it was made.
962
+ assertDeviceModeApplicable(state);
963
+ const launchConfig = await buildLaunchConfigFromState(state);
964
+ // Post-boot auth, not factory admin: a machine provisioned with
965
+ // `--disable-admin` answers 401 to `admin:`, and those are exactly the
966
+ // machines this route exists for.
967
+ await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log, resolveAuth(state).header);
968
+
969
+ // Persist what is now true of the guest: the applied selection folded into
970
+ // what was already *applied*, not the request on its own. Device-mode
971
+ // updates are cumulative — only the settings a request names move — so
972
+ // overwriting would leave `machine.json` describing a machine that does
973
+ // not exist.
974
+ //
975
+ // `appliedDeviceModeRecord` gates the carry-forward on the step record, for
976
+ // the reason #176 keeps running into: desired config is not evidence that
977
+ // a step ran.
978
+ const deviceMode = appliedDeviceModeRecord(state, resolved);
979
+ const current = loadMachine(state.name);
980
+ if (current) {
981
+ current.deviceMode = deviceMode;
982
+ saveMachine(current);
983
+ }
984
+ state.deviceMode = deviceMode;
985
+ // This is quickchr applying a provisioning step, so it belongs in the
986
+ // record the window reads (#176). It does not reopen the window — it adds
987
+ // the one step that just ran, so a later `start` passing the same
988
+ // device-mode is recognised as already applied instead of refused.
989
+ recordProvisioningStep(state, "deviceMode");
990
+ } finally {
991
+ lock.release();
779
992
  }
780
- state.deviceMode = options;
781
993
  },
782
994
 
783
995
  async availablePackages(): Promise<string[]> {
@@ -864,8 +1076,10 @@ function createInstance(state: MachineState): ChrInstance {
864
1076
  },
865
1077
 
866
1078
  async destroy(): Promise<void> {
867
- await this.stop();
868
- await this.remove();
1079
+ await withMachineLock(state, "remove", async () => {
1080
+ await stopUnderLock(state);
1081
+ await removeUnderLock(state);
1082
+ });
869
1083
  },
870
1084
 
871
1085
  async subprocessEnv(): Promise<Record<string, string>> {
@@ -1005,44 +1219,6 @@ function createInstance(state: MachineState): ChrInstance {
1005
1219
  };
1006
1220
  }
1007
1221
 
1008
- /**
1009
- * Atomically acquire an exclusive start lock for a machine directory.
1010
- * Uses O_CREAT|O_EXCL so concurrent attempts are rejected by the OS — no TOCTOU race.
1011
- * If the lock is stale (owning process is dead) it is silently replaced.
1012
- * Throws MACHINE_LOCKED if the lock is held by a live process.
1013
- */
1014
- export function acquireLock(lockPath: string): void {
1015
- let fd: number;
1016
- try {
1017
- fd = openSync(lockPath, "wx"); // O_WRONLY | O_CREAT | O_EXCL — atomic
1018
- writeSync(fd, String(process.pid));
1019
- closeSync(fd);
1020
- return;
1021
- } catch {
1022
- // File already exists — check whether owner is still alive
1023
- }
1024
-
1025
- let ownerPid: number | undefined;
1026
- try {
1027
- ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
1028
- } catch { /* unreadable — treat as stale */ }
1029
-
1030
- if (ownerPid && !Number.isNaN(ownerPid)) {
1031
- try {
1032
- process.kill(ownerPid, 0); // probes liveness; throws ESRCH if process not found
1033
- throw new QuickCHRError("MACHINE_LOCKED", `Machine is already being started (pid ${ownerPid})`);
1034
- } catch (e) {
1035
- if (e instanceof QuickCHRError) throw e;
1036
- // Process is dead — stale lock, overwrite
1037
- writeFileSync(lockPath, String(process.pid));
1038
- return;
1039
- }
1040
- }
1041
-
1042
- // Owner unreadable or zero — treat as stale, overwrite
1043
- writeFileSync(lockPath, String(process.pid));
1044
- }
1045
-
1046
1222
  /** Resolve the host architecture to a CHR architecture. */
1047
1223
  function hostArchToChr(): Arch {
1048
1224
  const arch = process.arch;
@@ -1222,28 +1398,122 @@ export async function captureRunningFailure(
1222
1398
  });
1223
1399
  }
1224
1400
 
1225
- /** Tear down a machine that failed to boot, and describe what was done.
1226
- *
1227
- * Default is full cleanup — a failed `start()` must not leave a half-dead
1228
- * machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
1229
- * inverts that for **local** debugging: the QEMU process is still stopped (it is
1230
- * wedged, and its port block must be released), but the machine directory
1231
- * survives so it can be inspected by hand.
1232
- *
1233
- * Evidence does not depend on that flag, and CI deliberately does not set it —
1234
- * `captureBootFailure()` has already written its report outside the machine dir
1235
- * with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
1236
- async function cleanupAfterBootFailure(
1401
+ /** Cleanup intent is captured under the lifecycle lock, before launch mutates state. */
1402
+ export type BootFailureCleanup =
1403
+ | { kind: "remove-new" }
1404
+ | { kind: "reset-first-boot"; restore: MachineState }
1405
+ | { kind: "preserve-existing" };
1406
+
1407
+ /** Stop the failed guest, then apply the launch's cleanup intent.
1408
+ * Diagnostics have already been captured outside the machine directory. */
1409
+ export async function cleanupAfterBootFailure(
1237
1410
  instance: ChrInstance,
1238
1411
  name: string,
1239
1412
  failure: BootFailureReport,
1413
+ cleanup: BootFailureCleanup,
1240
1414
  ): Promise<string> {
1241
- try { await instance.stop(); } catch { /* ignore */ }
1415
+ const quoted = shellQuote(name);
1416
+ // Called from inside a start or relaunch that holds the machine lock.
1417
+ try {
1418
+ await stopUnderLock(instance.state);
1419
+ } catch (err) {
1420
+ const reason = err instanceof Error ? err.message : String(err);
1421
+ return ` Machine "${name}" was retained, but stopping QEMU failed (${reason}) — retry with \`quickchr stop ${quoted}\` before starting or removing it.`;
1422
+ }
1423
+ if (cleanup.kind === "preserve-existing") {
1424
+ return ` Machine "${name}" was stopped and preserved with its disks and configuration — ` +
1425
+ `retry with \`quickchr start ${quoted}\`, or explicitly remove it with \`quickchr remove ${quoted}\`.`;
1426
+ }
1427
+ const restore = cleanup.kind === "reset-first-boot" ? cleanup.restore : undefined;
1242
1428
  if (failure.preserved) {
1243
- return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
1429
+ return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — ` +
1430
+ (restore ? `reset it with \`quickchr clean ${quoted}\` and start it again, or ` : "") +
1431
+ `remove it with \`quickchr remove ${quoted}\`.`;
1432
+ }
1433
+ // An add()-created machine on its first boot: the same reset a failed provisioning
1434
+ // run gets (#187), so the retry is `start` again rather than `add` + `start`. The
1435
+ // guest may have written to its disk before stalling, so the disk goes back to the
1436
+ // factory image along with the record — the window is open again, and an open
1437
+ // window promises a factory disk.
1438
+ if (restore) {
1439
+ try {
1440
+ await resetGuestToFactory(restore);
1441
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1442
+ refreshMachineState(instance.state);
1443
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — ` +
1444
+ `retry with \`quickchr start ${quoted}\`. Set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine as it is for diagnosis.`;
1445
+ } catch (resetErr) {
1446
+ const reason = resetErr instanceof Error ? resetErr.message : String(resetErr);
1447
+ return ` Resetting machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1448
+ }
1449
+ }
1450
+ try { await removeUnderLock(instance.state); } catch { /* ignore */ }
1451
+ return ` Machine "${name}" has been cleaned up automatically — set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine for diagnosis.`;
1452
+ }
1453
+
1454
+ /** Undo a first boot whose provisioning threw, so that retrying is re-running the same
1455
+ * command (#176).
1456
+ *
1457
+ * Provisioning is transactional — you get the machine you asked for or you don't —
1458
+ * and the throw used to break that: the guest was left running and half-provisioned,
1459
+ * with `lastStartedAt` set, so the window was closed and the retry a user reaches for
1460
+ * was refused. Resuming from the steps that landed is not the fix. A step can throw
1461
+ * half-applied (the login group creates a user and then disables `admin` in one call),
1462
+ * and quickchr cannot tell how far it got without reading the guest. So the rollback
1463
+ * reverts the whole run, and the retry starts again from a factory disk:
1464
+ *
1465
+ * - **`restore` given** — an `add()`-created machine on its first boot. The disk is
1466
+ * reset to the factory image and `machine.json` goes back to the record `add()`
1467
+ * wrote, window open and intent intact, so `quickchr start <name>` tries again.
1468
+ * - **no `restore`** — `start()` created the machine in this call, so it is removed,
1469
+ * exactly as a boot timeout removes it.
1470
+ *
1471
+ * `QUICKCHR_PRESERVE_ON_FAILURE=1` keeps the half-provisioned machine for inspection,
1472
+ * as it does for a boot failure. QEMU is stopped either way.
1473
+ *
1474
+ * Returns the sentence to append to the error. A rollback that fails is reported in
1475
+ * that sentence, never swallowed — the provisioning error stays the primary one. */
1476
+ export async function rollbackFailedProvisioning(
1477
+ instance: ChrInstance,
1478
+ restore: MachineState | undefined,
1479
+ ): Promise<string> {
1480
+ const name = instance.state.name;
1481
+ const quoted = shellQuote(name);
1482
+ // Called from inside a start or relaunch that holds the machine lock.
1483
+ try {
1484
+ await stopUnderLock(instance.state);
1485
+ if (preserveOnFailure()) {
1486
+ return ` Machine "${name}" was preserved half-provisioned for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — reset it with \`quickchr clean ${quoted}\` and repeat the start command, or remove it with \`quickchr remove ${quoted}\`.`;
1487
+ }
1488
+ if (!restore) {
1489
+ await removeUnderLock(instance.state);
1490
+ return ` Machine "${name}" has been removed — re-run the same command to retry.`;
1491
+ }
1492
+ await resetGuestToFactory(restore);
1493
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1494
+ refreshMachineState(instance.state);
1495
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — re-run the same start command to retry.`;
1496
+ } catch (rollbackErr) {
1497
+ const reason = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1498
+ return ` Rolling back machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1499
+ }
1500
+ }
1501
+
1502
+ /** Append the rollback outcome to a provisioning error and hand it back for rethrow.
1503
+ * The error keeps its own type, code and stack: the rollback is context, not the
1504
+ * failure. */
1505
+ export async function failProvisioning(
1506
+ err: unknown,
1507
+ instance: ChrInstance,
1508
+ restore: MachineState | undefined,
1509
+ ): Promise<unknown> {
1510
+ // Its own line: the provisioning error often ends in a RouterOS JSON body.
1511
+ const outcome = (await rollbackFailedProvisioning(instance, restore)).trim();
1512
+ if (err instanceof Error) {
1513
+ err.message += `\n${outcome}`;
1514
+ return err;
1244
1515
  }
1245
- try { await instance.remove(); } catch { /* ignore */ }
1246
- return ` Machine "${name}" has been cleaned up automatically.`;
1516
+ return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
1247
1517
  }
1248
1518
 
1249
1519
  /** Wait for CHR to boot with periodic progress status updates every 20s. */
@@ -1337,26 +1607,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1337
1607
  };
1338
1608
  }
1339
1609
 
1610
+ /** Persist what provisioning landed on the current disk, in state and on disk.
1611
+ * A run with nothing to do still records — an empty `steps` says "provisioning ran
1612
+ * and had nothing to apply", which is a different fact from never having run. */
1613
+ function recordProvisioning(state: MachineState, steps: ProvisioningStep[]): void {
1614
+ const record = { at: new Date().toISOString(), steps };
1615
+ const current = loadMachine(state.name);
1616
+ if (current) {
1617
+ current.provisioning = record;
1618
+ saveMachine(current);
1619
+ }
1620
+ state.provisioning = record;
1621
+ }
1622
+
1623
+ /** Re-read `machine.json` into an existing state object, in place.
1624
+ *
1625
+ * **In place** because `createInstance` closes over this object and exposes it as
1626
+ * `instance.state`: rebinding a local would leave the caller's view stale, and every
1627
+ * other method on the handle would keep reading the old snapshot.
1628
+ *
1629
+ * Keys the reloaded record does not have are deleted rather than left behind.
1630
+ * `clean()` removes `provisioning`, `user` and `disableAdmin` precisely because they
1631
+ * stopped being true, so a leftover would be read as fact by the next caller —
1632
+ * `appliedDeviceModeRecord()` reads exactly one of them. A plain `Object.assign`
1633
+ * cannot express a field that went away. */
1634
+ export function refreshMachineState(state: MachineState): void {
1635
+ const fresh = loadMachine(state.name);
1636
+ if (!fresh) return;
1637
+ const mutable = state as unknown as Record<string, unknown>;
1638
+ for (const key of Object.keys(mutable)) {
1639
+ if (!(key in fresh)) delete mutable[key];
1640
+ }
1641
+ Object.assign(state, fresh);
1642
+ }
1643
+
1644
+ /** Add one step to the provisioning record, keeping whatever is already there.
1645
+ *
1646
+ * `recordProvisioning()` stamps a whole first-boot run; this is for a step applied on
1647
+ * its own afterwards — `setDeviceMode()` today, `installPackage()` when #24 lands.
1648
+ * `steps` accumulates and `at` moves to now, which is what `ProvisioningRecord`
1649
+ * promises: the last time quickchr applied provisioning to this disk.
1650
+ *
1651
+ * It cannot reopen or close a window by accident. The only caller requires a running
1652
+ * machine, and a running machine has `lastStartedAt`, so the window was already shut
1653
+ * before this wrote anything. */
1654
+ function recordProvisioningStep(state: MachineState, step: ProvisioningStep): void {
1655
+ const steps = [...new Set([...(state.provisioning?.steps ?? []), step])];
1656
+ const record = { at: new Date().toISOString(), steps };
1657
+ const current = loadMachine(state.name);
1658
+ if (current) {
1659
+ current.provisioning = record;
1660
+ saveMachine(current);
1661
+ }
1662
+ state.provisioning = record;
1663
+ }
1664
+
1665
+ /** Preconditions a post-boot device-mode change inherits from provisioning.
1666
+ *
1667
+ * Both were undocumented and both fail confusingly when unchecked (#176):
1668
+ * `applyDeviceMode()` polls `waitForDeviceModeApi` before it does anything, so a
1669
+ * stopped machine spends 60 s to report a timeout, and a machine with no user-mode
1670
+ * NIC has no localhost route to REST at all and never recovers.
1671
+ *
1672
+ * This is the same guard `start()` applies to provisioning, moved to the one step
1673
+ * that may also run afterwards. */
1674
+ function assertDeviceModeApplicable(state: MachineState): void {
1675
+ if (!isMachineRunning(state)) {
1676
+ throw new QuickCHRError(
1677
+ "MACHINE_STOPPED",
1678
+ `Device-mode is applied through the guest's REST API, so "${state.name}" has to be running. ` +
1679
+ `Start it with 'quickchr start ${shellQuote(state.name)}', then set device-mode.`,
1680
+ );
1681
+ }
1682
+ if (!hasUserModeNetwork(state.networks)) {
1683
+ throw new QuickCHRError(
1684
+ "NETWORK_UNAVAILABLE",
1685
+ `Device-mode is applied over localhost REST, which needs a user-mode network interface — ` +
1686
+ `"${state.name}" has none, so quickchr cannot reach its REST API. ` +
1687
+ "Recreate the machine with a user-mode NIC alongside its other networks.",
1688
+ );
1689
+ }
1690
+ }
1691
+
1340
1692
  /** Apply a device-mode change to a running CHR instance (may require hard power-cycle).
1341
- * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic. */
1693
+ * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
1694
+ *
1695
+ * `authHeader` is the caller's, because the two callers genuinely differ and the
1696
+ * difference is not cosmetic. During first-boot provisioning device-mode is step 2 and
1697
+ * the user step is step 4, so factory `admin:` is the *only* credential that exists
1698
+ * yet — `state.user` is already populated at that point but names an account nobody
1699
+ * has created, so resolving it here would 401 the shipped path. Post-boot the reverse
1700
+ * holds: a machine provisioned with `--disable-admin` answers 401 to factory admin,
1701
+ * which is what made `quickchr set <name> --device-mode` fail on exactly the machines
1702
+ * the route was written for. Same field, opposite answers, decided by the caller
1703
+ * rather than guessed here. */
1342
1704
  async function applyDeviceMode(
1343
1705
  instance: ChrInstance,
1344
1706
  machineState: MachineState,
1345
1707
  resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
1346
1708
  launchConfig: QemuLaunchConfig,
1347
1709
  log: ProgressLogger,
1710
+ authHeader: string,
1348
1711
  ): Promise<void> {
1349
1712
  const httpPort = toChrPorts(machineState.ports).http;
1350
1713
  const accel = await detectAccel(machineState.arch);
1351
1714
  const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
1352
- await waitForDeviceModeApi(httpPort, 60_000);
1715
+ await waitForDeviceModeApi(httpPort, 60_000, authHeader);
1353
1716
  log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
1354
1717
 
1355
1718
  let alreadyActive = false;
1356
1719
  try {
1357
- const beforeMode = await readDeviceMode(httpPort);
1720
+ const beforeMode = await readDeviceMode(httpPort, authHeader);
1358
1721
  log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
1359
1722
  alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
1723
+ // Say what the power cycle is buying before spending it. `mode` moves as a
1724
+ // side-effect of enabling a feature — `--device-mode-enable container` resolves
1725
+ // `auto` to `rose` — and a change nobody asked for should at least be announced
1726
+ // (#176).
1727
+ for (const change of describeDeviceModeChange(resolvedDeviceMode, beforeMode)) {
1728
+ log.status(` ${change}`);
1729
+ }
1360
1730
  } catch { /* CHR unexpectedly unreachable — proceed with update */ }
1361
1731
 
1362
1732
  if (alreadyActive) {
@@ -1369,7 +1739,7 @@ async function applyDeviceMode(
1369
1739
  let requiresPowerCycle = false;
1370
1740
 
1371
1741
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1372
- const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
1742
+ const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
1373
1743
  // RouterOS blocks this connection while waiting for hard power-cycle confirmation.
1374
1744
  // We race against 2s: if still pending at 2s, RouterOS has entered blocking state
1375
1745
  // and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
@@ -1399,6 +1769,22 @@ async function applyDeviceMode(
1399
1769
  }
1400
1770
 
1401
1771
  log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
1772
+
1773
+ // A rejection is an answer, not a race — say what RouterOS said and stop.
1774
+ // Retrying here used to cost five POSTs and ~33 s and then report a *mismatch*
1775
+ // (`bogus-feature: expected=yes, actual=(missing)`) while RouterOS had replied
1776
+ // `HTTP 400 — unknown parameter bogus-feature` on the first attempt. RouterOS
1777
+ // also counts device-mode update attempts (`attempt-count` on
1778
+ // `/system/device-mode`), so the retries spend a budget that is not ours to
1779
+ // spend. Only a 2xx that came back before activation is worth trying again.
1780
+ const { status, body } = outcome.response;
1781
+ if (status < 200 || status >= 300) {
1782
+ throw new QuickCHRError(
1783
+ "PROCESS_FAILED",
1784
+ `Device-mode update rejected by RouterOS: HTTP ${status} — ${body.trim() || "(empty body)"}`,
1785
+ );
1786
+ }
1787
+
1402
1788
  await Bun.sleep(5000);
1403
1789
 
1404
1790
  let routerOsOffline = false;
@@ -1415,10 +1801,10 @@ async function applyDeviceMode(
1415
1801
  if (!rebooted) {
1416
1802
  throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
1417
1803
  }
1418
- await waitForDeviceModeApi(httpPort, bootTimeout);
1804
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1419
1805
  }
1420
1806
 
1421
- const actualNow = await readDeviceMode(httpPort);
1807
+ const actualNow = await readDeviceMode(httpPort, authHeader);
1422
1808
  log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
1423
1809
  const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
1424
1810
  if (immediateVerification.ok) {
@@ -1448,10 +1834,10 @@ async function applyDeviceMode(
1448
1834
  `Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
1449
1835
  );
1450
1836
  }
1451
- await waitForDeviceModeApi(httpPort, bootTimeout);
1837
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1452
1838
  }
1453
1839
 
1454
- const actual = await readDeviceMode(httpPort);
1840
+ const actual = await readDeviceMode(httpPort, authHeader);
1455
1841
  log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
1456
1842
  const verification = verifyDeviceMode(resolvedDeviceMode, actual);
1457
1843
  if (!verification.ok) {
@@ -1463,6 +1849,112 @@ async function applyDeviceMode(
1463
1849
  log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
1464
1850
  }
1465
1851
 
1852
+ /** How long a creation waits for another to finish choosing its port block. The
1853
+ * critical section is a handful of loopback connect probes, so this is headroom for a
1854
+ * busy host, not an expected wait. */
1855
+ const ALLOCATION_LOCK_WAIT_MS = 60_000;
1856
+
1857
+ /** An explicit `portBase` is the caller's choice and is never moved: one that overlaps
1858
+ * another machine's block — persisted or still being created — is a `PORT_CONFLICT`
1859
+ * here rather than a `SPAWN_FAILED` from QEMU's bind later. */
1860
+ function assertPortBaseFree(
1861
+ portBase: number,
1862
+ name: string,
1863
+ machines: MachineState[],
1864
+ reserved: Array<{ name: string; portBase: number }>,
1865
+ ): void {
1866
+ const others = [
1867
+ ...machines.filter((m) => m.name !== name).map((m) => ({ name: m.name, portBase: m.portBase })),
1868
+ ...reserved,
1869
+ ];
1870
+ const clash = others.find((o) => Math.abs(o.portBase - portBase) < PORTS_PER_BLOCK);
1871
+ if (clash) {
1872
+ throw new QuickCHRError(
1873
+ "PORT_CONFLICT",
1874
+ `portBase ${portBase} overlaps machine "${clash.name}" (portBase ${clash.portBase}) — each machine takes a block of ${PORTS_PER_BLOCK} ports. Pick a base at least ${PORTS_PER_BLOCK} away, or omit portBase to have one allocated.`,
1875
+ );
1876
+ }
1877
+ }
1878
+
1879
+ /** Choose a new machine's port block and NIC MACs, avoiding every persisted machine
1880
+ * and every creation still in flight. Choosing is not claiming: only
1881
+ * `claimNewMachine()` makes the result safe from a concurrent creation. */
1882
+ async function pickNewMachineResources(
1883
+ name: string,
1884
+ opts: StartOptions,
1885
+ ): Promise<{ portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1886
+ const machines = loadAllMachines();
1887
+ const reserved = listReservations(name);
1888
+ if (opts.portBase) assertPortBaseFree(opts.portBase, name, machines, reserved);
1889
+ const usedBases = [...getUsedPortBases(), ...reserved.map((r) => r.portBase)];
1890
+ const portBase = opts.portBase || await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1891
+ validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, machines, name);
1892
+ const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
1893
+
1894
+ const usedMacs = getUsedMacs();
1895
+ for (const r of reserved) for (const mac of r.macs) usedMacs.add(mac);
1896
+ const networks = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), usedMacs);
1897
+ return { portBase, ports, networks };
1898
+ }
1899
+
1900
+ /** Choose a new machine's resources and take its machine lock with them recorded in
1901
+ * it, all while holding the data-dir allocation lock (#140).
1902
+ *
1903
+ * Without this, two concurrent creations both read the same machine list, both probe
1904
+ * the same free ports, and both pick the same block; one QEMU then fails its
1905
+ * `hostfwd` bind. Nothing persists the choice until `saveMachine()`, which for
1906
+ * `start()` is after the download and the spawn, so the choice itself has to be
1907
+ * visible: the machine lock carries it as a reservation, `listReservations()` reads
1908
+ * it, and it ends when the lock is released — by which time `machine.json` holds it,
1909
+ * or the creation failed and there is nothing left to hold. A crashed creator's
1910
+ * reservation dies with its pid.
1911
+ *
1912
+ * The machine lock is fail-fast. A live holder is another create of the same name. */
1913
+ export async function claimNewMachine(
1914
+ name: string,
1915
+ op: Extract<LockOperation, "add" | "start" | "foreground">,
1916
+ opts: StartOptions,
1917
+ ): Promise<{ lock: HeldLock; portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1918
+ ensureDir(getDataDir());
1919
+ const allocation = await acquireLock(allocationLockPath(), "allocate", {
1920
+ waitMs: ALLOCATION_LOCK_WAIT_MS,
1921
+ subject: "The port allocator",
1922
+ });
1923
+ try {
1924
+ const picked = await pickNewMachineResources(name, opts);
1925
+ const machineDir = getMachineDir(name);
1926
+ const lockOpts = {
1927
+ ...machineLockOptions(name, machineDir),
1928
+ reserve: {
1929
+ portBase: picked.portBase,
1930
+ macs: picked.networks.flatMap((n) => (n.mac ? [n.mac] : [])),
1931
+ },
1932
+ };
1933
+ // The lock lives in the directory, so the directory comes first. A refusal here
1934
+ // means a live holder owns the directory — a create of the same name, or a
1935
+ // remove — and it, not this call, cleans it up. A remove renames the directory
1936
+ // away in one step; if that lands between the two lines, make a fresh one.
1937
+ let lock: HeldLock | undefined;
1938
+ for (let tries = 0; !lock; tries++) {
1939
+ ensureDir(machineDir);
1940
+ try {
1941
+ lock = acquireLockNow(machineLockPath(machineDir), op, lockOpts);
1942
+ } catch (e) {
1943
+ if (!(e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") || tries >= 2) throw e;
1944
+ }
1945
+ }
1946
+ // Another create of this name may have finished between the caller's existence
1947
+ // check and this lock.
1948
+ if (existsSync(join(machineDir, "machine.json"))) {
1949
+ lock.release();
1950
+ throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" was created by another process while this one was starting.`);
1951
+ }
1952
+ return { lock, ...picked };
1953
+ } finally {
1954
+ allocation.release();
1955
+ }
1956
+ }
1957
+
1466
1958
  /**
1467
1959
  * Main entry point for managing MikroTik CHR virtual machines via QEMU.
1468
1960
  *
@@ -1488,6 +1980,7 @@ export class QuickCHR {
1488
1980
  // add() always creates, so the full name rules apply before any I/O.
1489
1981
  if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
1490
1982
 
1983
+ warnVersionWithChannel(opts, createLogger(opts.onProgress));
1491
1984
  let version: string;
1492
1985
  if (opts.version) {
1493
1986
  if (!isValidVersion(opts.version)) {
@@ -1500,13 +1993,10 @@ export class QuickCHR {
1500
1993
  }
1501
1994
 
1502
1995
  const arch: Arch = resolveArch(opts.arch);
1503
- requireQemu(arch);
1504
- if (arch === "arm64") requireFirmware();
1505
- const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1996
+ assertRouterOs6Arch(version, arch);
1506
1997
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
1507
1998
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1508
-
1509
- if (hasProvisioningMutations({
1999
+ const mutations = {
1510
2000
  installAllPackages: opts.installAllPackages,
1511
2001
  packages: opts.packages,
1512
2002
  hasDeviceModeProvisioning,
@@ -1514,17 +2004,15 @@ export class QuickCHR {
1514
2004
  disableAdmin: opts.disableAdmin,
1515
2005
  license: opts.license,
1516
2006
  secureLogin: opts.secureLogin,
1517
- })) {
1518
- assertProvisioningSupportedVersion(version, describeProvisioningOperation({
1519
- installAllPackages: opts.installAllPackages,
1520
- packages: opts.packages,
1521
- hasDeviceModeProvisioning,
1522
- user: opts.user,
1523
- disableAdmin: opts.disableAdmin,
1524
- license: opts.license,
1525
- secureLogin: opts.secureLogin,
1526
- }));
2007
+ };
2008
+ // Version refusals before host checks: a request that can never succeed should
2009
+ // say why, not first ask for QEMU to be installed.
2010
+ if (hasProvisioningMutations(mutations)) {
2011
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
1527
2012
  }
2013
+ requireQemu(arch);
2014
+ if (arch === "arm64") requireFirmware();
2015
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1528
2016
 
1529
2017
  const existingNames = listMachineNames();
1530
2018
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
@@ -1540,16 +2028,9 @@ export class QuickCHR {
1540
2028
  : new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
1541
2029
  }
1542
2030
 
1543
- const usedBases = getUsedPortBases();
1544
- const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1545
- validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
1546
- const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
1547
-
1548
2031
  const machineDir = getMachineDir(name);
1549
2032
  assertSufficientQuickchrStorage(`prepare CHR ${name}`);
1550
- ensureDir(machineDir);
1551
- const lockPath = join(machineDir, ".start-lock");
1552
- acquireLock(lockPath);
2033
+ const { lock, portBase, ports, networks } = await claimNewMachine(name, "add", opts);
1553
2034
  try {
1554
2035
  const cachedImg = await ensureCachedImage(version, arch);
1555
2036
  copyImageToMachine(cachedImg, machineDir);
@@ -1566,7 +2047,7 @@ export class QuickCHR {
1566
2047
  arch,
1567
2048
  cpu: opts.cpu ?? 1,
1568
2049
  mem: defaultMem(arch, opts.mem),
1569
- networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
2050
+ networks,
1570
2051
  ports,
1571
2052
  packages: opts.packages ?? [],
1572
2053
  installAllPackages: opts.installAllPackages,
@@ -1597,11 +2078,11 @@ export class QuickCHR {
1597
2078
  // directory it was supposed to clean up. Nothing runs after saveMachine()
1598
2079
  // but `return state`, so a readable file here means the create succeeded.
1599
2080
  if (isOrphanMachineDir(name)) {
1600
- try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
2081
+ try { removeState(name); } catch { /* best effort */ }
1601
2082
  }
1602
2083
  throw err;
1603
2084
  } finally {
1604
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2085
+ lock.release();
1605
2086
  }
1606
2087
  }
1607
2088
 
@@ -1609,7 +2090,9 @@ export class QuickCHR {
1609
2090
  * Start a new or existing CHR instance.
1610
2091
  *
1611
2092
  * The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
1612
- * device-mode, user) has completed before this promise resolves.
2093
+ * device-mode, user) has completed before this promise resolves. Two exceptions:
2094
+ * `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
2095
+ * a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
1613
2096
  * Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
1614
2097
  * stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
1615
2098
  * apply when creating a new machine and require `qemu-img` on the host.
@@ -1623,6 +2106,13 @@ export class QuickCHR {
1623
2106
  throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
1624
2107
  }
1625
2108
 
2109
+ if (opts.wait === false && opts.background === false) {
2110
+ throw new QuickCHRError(
2111
+ "INVALID_ARGUMENT",
2112
+ "--no-wait (wait: false) cannot be combined with --fg (background: false): a foreground start holds the serial console until QEMU exits, so there is nothing to return early from.",
2113
+ );
2114
+ }
2115
+
1626
2116
  const logger = createLogger(opts.onProgress);
1627
2117
 
1628
2118
  // Before any download or disk work: a named socket whose two ends are already
@@ -1638,6 +2128,7 @@ export class QuickCHR {
1638
2128
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1639
2129
 
1640
2130
  // Resolve version
2131
+ warnVersionWithChannel(opts, logger);
1641
2132
  let version: string;
1642
2133
  if (opts.version) {
1643
2134
  if ((CHANNELS as readonly string[]).includes(opts.version)) {
@@ -1655,7 +2146,7 @@ export class QuickCHR {
1655
2146
  version = await resolveVersion(channel);
1656
2147
  }
1657
2148
 
1658
- if (hasProvisioningMutations({
2149
+ const mutations = {
1659
2150
  installAllPackages: opts.installAllPackages,
1660
2151
  packages: opts.packages,
1661
2152
  hasDeviceModeProvisioning,
@@ -1663,29 +2154,20 @@ export class QuickCHR {
1663
2154
  disableAdmin: opts.disableAdmin,
1664
2155
  license: opts.license,
1665
2156
  secureLogin: opts.secureLogin,
1666
- })) {
1667
- assertProvisioningSupportedVersion(version, describeProvisioningOperation({
1668
- installAllPackages: opts.installAllPackages,
1669
- packages: opts.packages,
1670
- hasDeviceModeProvisioning,
1671
- user: opts.user,
1672
- disableAdmin: opts.disableAdmin,
1673
- license: opts.license,
1674
- secureLogin: opts.secureLogin,
1675
- }));
2157
+ };
2158
+ if (hasProvisioningMutations(mutations)) {
2159
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
2160
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
1676
2161
  }
1677
2162
 
1678
2163
  // Resolve architecture
1679
2164
  const arch: Arch = resolveArch(opts.arch);
1680
- const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1681
-
1682
- // Check prerequisites (skip for dry-run — no QEMU needed)
1683
- if (!opts.dryRun) {
1684
- requireQemu(arch);
1685
- if (arch === "arm64") {
1686
- requireFirmware();
1687
- }
1688
- }
2165
+ // Host prerequisites are checked just before each launch below, after every
2166
+ // refusal: a request that cannot succeed says why before asking for QEMU.
2167
+ const requireHostFor = (target: Arch) => {
2168
+ requireQemu(target);
2169
+ if (target === "arm64") requireFirmware();
2170
+ };
1689
2171
 
1690
2172
  // Resolve name
1691
2173
  const existingNames = listMachineNames();
@@ -1696,20 +2178,45 @@ export class QuickCHR {
1696
2178
  // Check if machine already exists
1697
2179
  const existing = loadMachine(name);
1698
2180
  if (existing) {
2181
+ // Both paths below re-launch a guest quickchr did not just create, so neither
2182
+ // can run provisioning. Refuse before either of them: dropping the options in
2183
+ // silence is what #176 removes, and the running-machine path is the quieter of
2184
+ // the two — it returns a handle without so much as a boot.
2185
+ const request = {
2186
+ installAllPackages: opts.installAllPackages,
2187
+ packages: opts.packages,
2188
+ deviceMode: opts.deviceMode,
2189
+ user: opts.user,
2190
+ disableAdmin: opts.disableAdmin,
2191
+ license: opts.license,
2192
+ secureLogin: opts.secureLogin,
2193
+ };
2194
+ // Version before window: on a version that can never be provisioned, the
2195
+ // window refusal's `quickchr clean` route leads to a second refusal (#182).
2196
+ if (hasProvisioningRequest(request)) {
2197
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation({
2198
+ ...request,
2199
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)),
2200
+ }));
2201
+ }
2202
+ const satisfied = assertProvisioningWindow(existing, request);
2203
+ for (const ask of satisfied) {
2204
+ (logger ?? createLogger()).status(`${ask.description} is already applied — nothing to do.`);
2205
+ }
1699
2206
  if (isMachineRunning(existing)) {
1700
2207
  return createInstance(existing);
1701
2208
  }
1702
2209
  // First boot of add()-created machine: apply pending provisioning from stored state
1703
- if (!existing.lastStartedAt) {
1704
- const pendingOpts = {
1705
- installAllPackages: opts.installAllPackages ?? existing.installAllPackages,
1706
- packages: opts.packages?.length ? opts.packages : (existing.packages.length > 0 ? existing.packages : undefined),
1707
- deviceMode: opts.deviceMode ?? existing.deviceMode,
1708
- user: opts.user ?? existing.user,
1709
- disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
2210
+ if (isProvisioningWindowOpen(existing)) {
2211
+ const pendingOpts = pendingProvisioningFor(existing, {
2212
+ installAllPackages: opts.installAllPackages,
2213
+ packages: opts.packages,
2214
+ deviceMode: opts.deviceMode,
2215
+ user: opts.user,
2216
+ disableAdmin: opts.disableAdmin,
1710
2217
  license: opts.license,
1711
- secureLogin: opts.secureLogin ?? existing.secureLogin,
1712
- };
2218
+ secureLogin: opts.secureLogin,
2219
+ });
1713
2220
  const hasPending = !!(
1714
2221
  pendingOpts.installAllPackages ||
1715
2222
  (pendingOpts.packages?.length ?? 0) > 0 ||
@@ -1720,49 +2227,43 @@ export class QuickCHR {
1720
2227
  pendingOpts.secureLogin === true
1721
2228
  );
1722
2229
  if (hasPending) {
2230
+ // Before the boot, not after it: `_provisionInstance()` re-checks the
2231
+ // version, but only once the guest is up — on RouterOS 6 that is a
2232
+ // download-free boot spent on a refusal known in advance.
2233
+ const pendingMutations = {
2234
+ ...pendingOpts,
2235
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(pendingOpts.deviceMode)),
2236
+ };
2237
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
2238
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
2239
+ requireHostFor(existing.arch);
1723
2240
  return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
1724
2241
  }
1725
2242
  }
1726
2243
  // Exists but stopped — restart it
1727
- return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger);
2244
+ requireHostFor(existing.arch);
2245
+ return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
1728
2246
  }
1729
2247
 
1730
- // Allocate port block
1731
- const usedBases = getUsedPortBases();
1732
- const portBase = opts.portBase
1733
- ? opts.portBase
1734
- : await findAvailablePortBlock(
1735
- usedBases,
1736
- opts.excludePorts,
1737
- opts.extraPorts,
1738
- );
1739
-
1740
- validateExplicitExtraPorts(
1741
- opts.extraPorts,
1742
- portBase,
1743
- opts.excludePorts,
1744
- loadAllMachines(),
1745
- name,
1746
- );
1747
-
1748
- const ports = buildPortMappings(
1749
- portBase,
1750
- opts.excludePorts,
1751
- opts.extraPorts,
1752
- );
2248
+ assertRouterOs6Arch(version, arch);
2249
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
2250
+ if (!opts.dryRun) requireHostFor(arch);
1753
2251
 
1754
- // Dry run — return instance handle without actually spawning
2252
+ // Dry run — return instance handle without actually spawning. Nothing is
2253
+ // claimed: a dry run persists nothing for another create to collide with.
1755
2254
  if (opts.dryRun) {
1756
2255
  const machineDir = getMachineDir(name);
2256
+ const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
1757
2257
  const state: MachineState = {
1758
2258
  name,
1759
2259
  version,
1760
2260
  arch,
1761
2261
  cpu: opts.cpu ?? 1,
1762
2262
  mem: defaultMem(arch, opts.mem),
1763
- networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
2263
+ networks,
1764
2264
  ports,
1765
2265
  packages: opts.packages ?? [],
2266
+ installAllPackages: opts.installAllPackages,
1766
2267
  deviceMode: requestedDeviceMode,
1767
2268
  user: opts.user,
1768
2269
  disableAdmin: opts.disableAdmin,
@@ -1780,13 +2281,31 @@ export class QuickCHR {
1780
2281
  return createInstance(state);
1781
2282
  }
1782
2283
 
1783
- // Acquire a lock to prevent concurrent starts of the same machine
1784
2284
  const machineDir = getMachineDir(name);
1785
2285
  assertSufficientQuickchrStorage(`start CHR ${name}`);
1786
2286
  const dirCreatedHere = !existsSync(machineDir);
1787
- ensureDir(machineDir);
1788
- const lockPath = join(machineDir, ".start-lock");
1789
- acquireLock(lockPath);
2287
+
2288
+ const background = opts.background ?? true;
2289
+
2290
+ // Provisioning includes any work that requires the machine to have booted first.
2291
+ // When foreground mode is requested WITH provisioning, we must boot in background,
2292
+ // provision (packages/user/license), then attach the serial socket to stdio.
2293
+ // Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
2294
+ const hasProvisioning = !!(
2295
+ opts.installAllPackages ||
2296
+ (opts.packages && opts.packages.length > 0) ||
2297
+ opts.user ||
2298
+ opts.disableAdmin ||
2299
+ opts.license ||
2300
+ hasDeviceModeProvisioning ||
2301
+ opts.secureLogin === true
2302
+ );
2303
+
2304
+ const { lock, portBase, ports, networks: networkConfigs } = await claimNewMachine(
2305
+ name,
2306
+ !background && !hasProvisioning ? "foreground" : "start",
2307
+ opts,
2308
+ );
1790
2309
  // Endpoints are claimed before QEMU is spawned, because resolution needs to know
1791
2310
  // which end this machine holds. A launch that fails before its state is persisted
1792
2311
  // has no instance lifecycle to release them later, so a failed start would leave a
@@ -1809,24 +2328,6 @@ export class QuickCHR {
1809
2328
  const bootDisk = diskArtifacts.bootDisk;
1810
2329
  const extraDiskConfigs = diskArtifacts.extraDisks;
1811
2330
 
1812
- // Build machine config
1813
- const background = opts.background ?? true;
1814
-
1815
- // Provisioning includes any work that requires the machine to have booted first.
1816
- // When foreground mode is requested WITH provisioning, we must boot in background,
1817
- // provision (packages/user/license), then attach the serial socket to stdio.
1818
- // Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
1819
- const hasProvisioning = !!(
1820
- opts.installAllPackages ||
1821
- (opts.packages && opts.packages.length > 0) ||
1822
- opts.user ||
1823
- opts.disableAdmin ||
1824
- opts.license ||
1825
- hasDeviceModeProvisioning ||
1826
- opts.secureLogin === true
1827
- );
1828
-
1829
- const networkConfigs = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs());
1830
2331
  if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1831
2332
  throw new QuickCHRError(
1832
2333
  "NETWORK_UNAVAILABLE",
@@ -1845,6 +2346,7 @@ export class QuickCHR {
1845
2346
  networks: networkConfigs,
1846
2347
  ports,
1847
2348
  packages: opts.packages ?? [],
2349
+ installAllPackages: opts.installAllPackages,
1848
2350
  deviceMode: requestedDeviceMode,
1849
2351
  user: opts.user,
1850
2352
  disableAdmin: opts.disableAdmin,
@@ -1914,6 +2416,7 @@ export class QuickCHR {
1914
2416
  unpersistedSocketClaim = undefined;
1915
2417
 
1916
2418
  const instance = createInstance(state);
2419
+ if (opts.wait === false) return instance;
1917
2420
 
1918
2421
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1919
2422
 
@@ -1963,7 +2466,7 @@ export class QuickCHR {
1963
2466
  "BOOT_TIMEOUT",
1964
2467
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1965
2468
  (hasProvisioning ? " — provisioning could not run." : ".") +
1966
- await cleanupAfterBootFailure(instance, name, failure) +
2469
+ await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
1967
2470
  `\n${failure.summary}`,
1968
2471
  );
1969
2472
  }
@@ -1987,15 +2490,20 @@ export class QuickCHR {
1987
2490
  } catch { /* never propagate */ }
1988
2491
 
1989
2492
  if (hasProvisioning) {
1990
- await QuickCHR._provisionInstance(instance, state, {
1991
- installAllPackages: opts.installAllPackages,
1992
- packages: opts.packages,
1993
- deviceMode: requestedDeviceMode,
1994
- user: opts.user,
1995
- disableAdmin: opts.disableAdmin,
1996
- license: opts.license,
1997
- secureLogin: opts.secureLogin,
1998
- }, launchConfig, logger);
2493
+ try {
2494
+ await QuickCHR._provisionInstance(instance, state, {
2495
+ installAllPackages: opts.installAllPackages,
2496
+ packages: opts.packages,
2497
+ deviceMode: requestedDeviceMode,
2498
+ user: opts.user,
2499
+ disableAdmin: opts.disableAdmin,
2500
+ license: opts.license,
2501
+ secureLogin: opts.secureLogin,
2502
+ }, launchConfig, logger);
2503
+ } catch (err) {
2504
+ // This call created the machine, so a failed run takes it away again.
2505
+ throw await failProvisioning(err, instance, undefined);
2506
+ }
1999
2507
  }
2000
2508
 
2001
2509
  // Foreground + provisioning: all provisioning is done in background mode.
@@ -2003,12 +2511,12 @@ export class QuickCHR {
2003
2511
  // QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
2004
2512
  // shortcuts that are well-documented and googleable).
2005
2513
  if (!background && hasProvisioning) {
2006
- await instance.stop();
2514
+ await stopUnderLock(state);
2007
2515
  // Brief pause for QEMU to flush and release the disk image
2008
2516
  await Bun.sleep(1000);
2009
- // Release lock before re-launching — _launchExisting acquires its own.
2010
- // The outer finally block will attempt a second unlink; that is harmless.
2011
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2517
+ // Release the lock before re-launching — _launchExisting acquires its own.
2518
+ // The outer finally releases again, which is a no-op.
2519
+ lock.release();
2012
2520
  return QuickCHR._launchExisting(state, false, undefined, logger);
2013
2521
  }
2014
2522
 
@@ -2026,11 +2534,11 @@ export class QuickCHR {
2026
2534
  // that already exist, and a directory this call did not create is never ours
2027
2535
  // to delete, whatever state it is in.
2028
2536
  if (dirCreatedHere && isOrphanMachineDir(name)) {
2029
- try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
2537
+ try { removeState(name); } catch { /* best effort */ }
2030
2538
  }
2031
2539
  throw err;
2032
2540
  } finally {
2033
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2541
+ lock.release();
2034
2542
  }
2035
2543
  }
2036
2544
 
@@ -2052,6 +2560,9 @@ export class QuickCHR {
2052
2560
  logger?: ProgressLogger,
2053
2561
  ): Promise<void> {
2054
2562
  const log = logger ?? createLogger();
2563
+ // What actually ran, persisted at the end as MachineState.provisioning — the
2564
+ // record that says this guest has been provisioned, and with what (#176).
2565
+ const applied: ProvisioningStep[] = [];
2055
2566
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
2056
2567
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
2057
2568
  assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
@@ -2082,6 +2593,7 @@ export class QuickCHR {
2082
2593
  saveMachine(current);
2083
2594
  }
2084
2595
  machineState.packages = installed;
2596
+ applied.push("packages");
2085
2597
  } else if (opts.packages && opts.packages.length > 0) {
2086
2598
  const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
2087
2599
  await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
@@ -2091,10 +2603,14 @@ export class QuickCHR {
2091
2603
  saveMachine(current);
2092
2604
  }
2093
2605
  machineState.packages = installed;
2606
+ applied.push("packages");
2094
2607
  }
2095
2608
 
2096
2609
  if (hasDeviceModeProvisioning) {
2097
- await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log);
2610
+ // Factory admin on purpose: this runs at step 2 and the user step is step 4,
2611
+ // so `machineState.user` names an account that does not exist yet.
2612
+ await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log, FACTORY_AUTH_HEADER);
2613
+ applied.push("deviceMode");
2098
2614
  }
2099
2615
 
2100
2616
  if (opts.license) {
@@ -2123,6 +2639,7 @@ export class QuickCHR {
2123
2639
  saveMachine(current);
2124
2640
  }
2125
2641
  machineState.licenseLevel = actualLevel as LicenseLevel;
2642
+ applied.push("license");
2126
2643
  log.status(` License applied: free → ${actualLevel}`);
2127
2644
  } catch (e) {
2128
2645
  if (e instanceof QuickCHRError) throw e;
@@ -2134,6 +2651,9 @@ export class QuickCHR {
2134
2651
  }
2135
2652
 
2136
2653
  if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
2654
+ if (opts.user) applied.push("user");
2655
+ if (opts.disableAdmin) applied.push("disableAdmin");
2656
+ if (opts.secureLogin === true) applied.push("secureLogin");
2137
2657
  const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
2138
2658
  if (result.user || result.managedSshKey) {
2139
2659
  // Persist user info + managed SSH key fact in state (password placeholder —
@@ -2161,6 +2681,11 @@ export class QuickCHR {
2161
2681
  }
2162
2682
  }
2163
2683
  }
2684
+
2685
+ // Stamped only here, after every requested step returned. A run that throws
2686
+ // leaves the record absent, and the caller rolls the whole run back
2687
+ // (`rollbackFailedProvisioning()`), so the retry starts from a factory disk.
2688
+ recordProvisioning(machineState, applied);
2164
2689
  }
2165
2690
 
2166
2691
  /** Re-launch an existing stopped machine. */
@@ -2177,14 +2702,47 @@ export class QuickCHR {
2177
2702
  secureLogin?: boolean;
2178
2703
  },
2179
2704
  logger?: ProgressLogger,
2705
+ wait = true,
2180
2706
  ): Promise<ChrInstance> {
2181
- const lockPath = join(state.machineDir, ".start-lock");
2182
- acquireLock(lockPath);
2707
+ const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
2708
+ opts && (
2709
+ opts.installAllPackages ||
2710
+ (opts.packages?.length ?? 0) > 0 ||
2711
+ opts.deviceMode ||
2712
+ opts.user ||
2713
+ opts.disableAdmin ||
2714
+ opts.license ||
2715
+ opts.secureLogin === true
2716
+ )
2717
+ );
2718
+ let hasProvisioning = wantsProvisioning(provisioningOpts);
2719
+ const lock = acquireLockNow(
2720
+ machineLockPath(state.machineDir),
2721
+ !background && !hasProvisioning ? "foreground" : "start",
2722
+ machineLockOptions(state.name, state.machineDir),
2723
+ );
2183
2724
  // Same pre-persistence window as start(): a relaunch that claims an endpoint and
2184
2725
  // then fails to spawn would keep it, with no instance to release it.
2185
2726
  let unpersistedSocketClaim: MachineState | undefined;
2186
2727
  try {
2187
2728
 
2729
+ // `state` is the caller's snapshot, read before the lock was taken; everything
2730
+ // below acts on it, `state.pid` included (#180). Re-read it, and if another
2731
+ // start got the machine running in between, hand back that one rather than
2732
+ // booting a second QEMU on the same disk.
2733
+ assertSameMachine(state);
2734
+ refreshMachineState(state);
2735
+ if (isMachineRunning(state)) return createInstance(state);
2736
+ // `provisioningOpts` was worked out from that snapshot too. If another start
2737
+ // booted the machine in between, the window is shut and running them would be
2738
+ // a second first boot: check them against the machine as it is now, and drop
2739
+ // whatever that start already applied.
2740
+ if (provisioningOpts && !isProvisioningWindowOpen(state)) {
2741
+ assertProvisioningWindow(state, provisioningOpts);
2742
+ provisioningOpts = undefined;
2743
+ hasProvisioning = false;
2744
+ }
2745
+
2188
2746
  const diskPath = join(state.machineDir, "disk.img");
2189
2747
  if (!existsSync(diskPath)) {
2190
2748
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
@@ -2199,19 +2757,17 @@ export class QuickCHR {
2199
2757
  const bootDisk = diskArtifacts.bootDisk;
2200
2758
  const extraDiskConfigs = diskArtifacts.extraDisks;
2201
2759
 
2202
- const hasProvisioning = !!(
2203
- provisioningOpts && (
2204
- provisioningOpts.installAllPackages ||
2205
- (provisioningOpts.packages?.length ?? 0) > 0 ||
2206
- provisioningOpts.deviceMode ||
2207
- provisioningOpts.user ||
2208
- provisioningOpts.disableAdmin ||
2209
- provisioningOpts.license ||
2210
- provisioningOpts.secureLogin === true
2211
- )
2212
- );
2213
2760
  // Always boot in background when provisioning is needed
2214
2761
  const spawnBackground = hasProvisioning ? true : background;
2762
+ // The record a failed first boot rolls back to — taken before this launch writes
2763
+ // pid, status or lastStartedAt into `state`. Whenever the window is open, not only
2764
+ // when there is provisioning to run: a boot timeout on an add()-created machine is
2765
+ // the same failed first boot as a provisioning error, and resets the same way
2766
+ // instead of deleting what `add` recorded (#182).
2767
+ const beforeLaunch = isProvisioningWindowOpen(state) ? structuredClone(state) : undefined;
2768
+ const bootFailureCleanup: BootFailureCleanup = beforeLaunch
2769
+ ? { kind: "reset-first-boot", restore: beforeLaunch }
2770
+ : { kind: "preserve-existing" };
2215
2771
 
2216
2772
  const platform = await detectPlatform();
2217
2773
  const accel = await detectAccel(state.arch);
@@ -2263,6 +2819,7 @@ export class QuickCHR {
2263
2819
  unpersistedSocketClaim = undefined;
2264
2820
 
2265
2821
  const instance = createInstance(state);
2822
+ if (!wait) return instance;
2266
2823
 
2267
2824
  // Always wait for boot in background mode — start() promises "REST-ready".
2268
2825
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
@@ -2292,7 +2849,7 @@ export class QuickCHR {
2292
2849
  "BOOT_TIMEOUT",
2293
2850
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
2294
2851
  (hasProvisioning ? " — provisioning could not run." : ".") +
2295
- await cleanupAfterBootFailure(instance, state.name, failure) +
2852
+ await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
2296
2853
  `\n${failure.summary}`,
2297
2854
  );
2298
2855
  }
@@ -2314,7 +2871,11 @@ export class QuickCHR {
2314
2871
  } catch { /* never propagate */ }
2315
2872
 
2316
2873
  if (hasProvisioning && provisioningOpts) {
2317
- await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2874
+ try {
2875
+ await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2876
+ } catch (err) {
2877
+ throw await failProvisioning(err, instance, beforeLaunch);
2878
+ }
2318
2879
  }
2319
2880
 
2320
2881
  try {
@@ -2330,7 +2891,7 @@ export class QuickCHR {
2330
2891
  if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2331
2892
  throw err;
2332
2893
  } finally {
2333
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2894
+ lock.release();
2334
2895
  }
2335
2896
  }
2336
2897
 
@@ -2386,20 +2947,20 @@ export class QuickCHR {
2386
2947
  // A create still in flight is indistinguishable from an orphan by directory
2387
2948
  // contents alone — add()/start() write machine.json last, so the window between
2388
2949
  // ensureDir() and saveMachine() looks exactly like a half-made machine. The
2389
- // start-lock is what tells them apart, so take it: acquireLock() throws
2390
- // MACHINE_LOCKED while a live creator holds it, and replaces one whose owner is
2391
- // gone — which is the case this recovery exists for.
2392
- const lockPath = join(getMachineDir(name), ".start-lock");
2393
- acquireLock(lockPath);
2394
-
2395
- // The creator may have finished between the check above and the lock.
2396
- if (!isOrphanMachineDir(name)) {
2397
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2398
- return false;
2950
+ // machine lock is what tells them apart, so take it: it throws MACHINE_LOCKED
2951
+ // while a live creator holds it, and takes over one whose owner is gone — which
2952
+ // is the case this recovery exists for. No waiting: a live creator means this is
2953
+ // not an orphan, and the answer is to let it finish.
2954
+ const lock = acquireLockNow(machineLockPath(getMachineDir(name)), "remove-orphan", machineLockOptions(name, getMachineDir(name)));
2955
+ try {
2956
+ // The creator may have finished between the check above and the lock.
2957
+ if (!isOrphanMachineDir(name)) return false;
2958
+ removeState(name);
2959
+ return true;
2960
+ } finally {
2961
+ // A no-op once the directory has gone; releases it if removal threw.
2962
+ lock.release();
2399
2963
  }
2400
-
2401
- removeState(name);
2402
- return true;
2403
2964
  }
2404
2965
 
2405
2966
  /** Run doctor checks for prerequisites. */