@tikoci/quickchr 0.4.8 → 0.5.1

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";
@@ -58,19 +62,20 @@ import {
58
62
  getDataDir,
59
63
  appendBootLog,
60
64
  } from "./state.ts";
61
- import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
65
+ import { ensureCachedImage, 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";
65
- import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
68
+ import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
69
+ import { bootDiskFormatOf, bootDiskPath, cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, writeBootDisk, 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,20 @@ 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
- import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
93
- import { existsSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
106
+ import { admitStorage, estimateMachineCreation, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
107
+ import { pinCacheEntries } from "./storage-lease.ts";
108
+ import { existsSync, rmSync } from "node:fs";
109
+ import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
94
110
  import { join } from "node:path";
95
111
 
96
112
  // --- Architecture-aware defaults ---
@@ -140,6 +156,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
140
156
  return opts;
141
157
  }
142
158
 
159
+ /** `version` and `channel` both name the release, so a caller passing both has asked
160
+ * for two things and gets one: `version` wins. Say so rather than drop `channel` in
161
+ * silence — the TSDoc used to call them "mutually exclusive" while nothing enforced
162
+ * it (#178). A warning, not a throw, matching the channel-name-as-version case. */
163
+ function warnVersionWithChannel(opts: StartOptions, logger: ProgressLogger): void {
164
+ if (opts.version && opts.channel) {
165
+ logger.warn(`Both version "${opts.version}" and channel "${opts.channel}" were given — using version ${opts.version}; channel is ignored.`);
166
+ }
167
+ }
168
+
143
169
  function listProvisioningMutations(opts: {
144
170
  installAllPackages?: boolean;
145
171
  packages?: string[];
@@ -185,6 +211,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
185
211
  return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
186
212
  }
187
213
 
214
+ /** `wait: false` returns before the guest has booted, and provisioning needs a booted
215
+ * guest — so the pair is refused by name rather than the provisioning silently
216
+ * skipped (#159). `machine` is set when the provisioning is first-boot intent that
217
+ * `add()` recorded, which the caller may not have typed on this command line. */
218
+ function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
219
+ if (opts.wait !== false) return;
220
+ const source = machine
221
+ ? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
222
+ : `provisioning was requested (${operation})`;
223
+ throw new QuickCHRError(
224
+ "INVALID_ARGUMENT",
225
+ `--no-wait (wait: false) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so start has to wait for it. ` +
226
+ "Drop --no-wait for this first boot — later starts can use it.",
227
+ );
228
+ }
229
+
188
230
  // --- Socket registry lifecycle helpers ---
189
231
 
190
232
  function getSocketNamedNetworks(state: MachineState): string[] {
@@ -316,6 +358,189 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
316
358
  return null;
317
359
  }
318
360
 
361
+ /** Put a machine's disks back to the factory image and drop the credential material
362
+ * that authenticated to the erased guest. Machine state is the caller's to update:
363
+ * `clean()` clears the guest facts and keeps intent, a provisioning rollback restores
364
+ * the record it had before the failed run. QEMU must already be stopped. */
365
+ async function resetGuestToFactory(state: MachineState): Promise<void> {
366
+ // NOTE: this deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
367
+ // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
368
+ // disk), not OS state; wiping it forces a full device scan (~480s) on the next
369
+ // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
370
+ // anchor: the "clean() resets disk to factory defaults" arm64 integration test
371
+ // (120s reboot wait would blow past 480s if the wipe were reintroduced).
372
+ // Re-copy fresh image from cache
373
+ const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
374
+ if (!existsSync(imgPath)) {
375
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
376
+ }
377
+ // Drop boot.qcow2 and the extra disks, then rebuild the boot disk from the cache.
378
+ // A raw disk.img is replaced atomically. On a qcow2 machine, disk.img is the unused
379
+ // raw copy machines created before 0.5.1 kept: the new boot.qcow2 is converted from
380
+ // the cache, so the copy goes too (#199).
381
+ const format = bootDiskFormatOf(state);
382
+ cleanDiskFiles(state.machineDir);
383
+ await writeBootDisk(state.machineDir, imgPath, format, state.bootSize);
384
+ if (format === "qcow2") rmSync(join(state.machineDir, "disk.img"), { force: true });
385
+ await ensureConfiguredDisks(state.machineDir, state.bootSize, state.extraDisks, format, { booted: false });
386
+
387
+ deleteInstanceCredentials(state.name);
388
+ // The managed keypair authenticated to the account factory reset erased,
389
+ // so it is dead credential material sitting in the machine dir. Removing
390
+ // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
391
+ // existing file exits 1 with "already exists" rather than overwriting
392
+ // (verified locally), so a leftover key would break `installSshKey()`.
393
+ rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
394
+ }
395
+
396
+ /** Options every machine-lock acquisition passes: the pre-0.5 lock to honour, and the
397
+ * noun for `MACHINE_LOCKED`. */
398
+ function machineLockOptions(name: string, machineDir: string): { legacyPath: string; subject: string } {
399
+ return { legacyPath: legacyMachineLockPath(machineDir), subject: `Machine "${name}"` };
400
+ }
401
+
402
+ /** The machine lock outlives its machine (see lock.ts), so a caller that has just
403
+ * taken it must check that the machine it was asked about is still there, and is not
404
+ * another machine created under the same name while it waited. Acting on the old
405
+ * snapshot would mean stopping a stale pid. */
406
+ function assertSameMachine(state: MachineState): void {
407
+ const fresh = loadMachine(state.name);
408
+ if (!fresh || fresh.createdAt !== state.createdAt) {
409
+ throw new QuickCHRError(
410
+ "MACHINE_NOT_FOUND",
411
+ `Machine "${state.name}" was removed${fresh ? " and created again" : ""} while this operation waited for it.`,
412
+ );
413
+ }
414
+ }
415
+
416
+ /** How long `stop`/`clean`/`remove` wait for an operation already holding the machine
417
+ * lock. The holder is almost always a start or relaunch waiting for boot, so the wait
418
+ * is that boot budget: a `stop` issued during a `start` runs after it rather than
419
+ * failing, and a holder that never finishes still cannot block it forever (#181). */
420
+ function lifecycleLockWaitMs(state: MachineState): number {
421
+ const withPackages = !!state.installAllPackages || (state.packages?.length ?? 0) > 0;
422
+ return defaultBootTimeout(state.arch, withPackages, state.lastAccel);
423
+ }
424
+
425
+ /** Run a lifecycle operation under the machine's lock, waiting for a current holder.
426
+ * State is re-read once the lock is held: a handle's snapshot predates whatever the
427
+ * holder just did, and `state.pid` read from it may name a process that has exited or
428
+ * one the OS has since reused (#180, #181). */
429
+ async function withMachineLock<T>(state: MachineState, op: LockOperation, fn: () => Promise<T>): Promise<T> {
430
+ const lock = await acquireLock(machineLockPath(state.machineDir), op, {
431
+ ...machineLockOptions(state.name, state.machineDir),
432
+ waitMs: lifecycleLockWaitMs(state),
433
+ onWait: (holder) => createLogger().warn(
434
+ `Machine "${state.name}" is busy with \`${holder.op}\` (pid ${holder.pid}) — waiting for it to finish before \`${op}\`.`,
435
+ ),
436
+ });
437
+ try {
438
+ assertSameMachine(state);
439
+ refreshMachineState(state);
440
+ return await fn();
441
+ } finally {
442
+ lock.release();
443
+ }
444
+ }
445
+
446
+ /** The body of `stop()`, for a caller that already holds the machine lock. The lock is
447
+ * not reentrant, so code inside a locked start or relaunch calls this, never
448
+ * `instance.stop()`. */
449
+ async function stopUnderLock(state: MachineState): Promise<void> {
450
+ if (state.pid) {
451
+ await stopQemu(state.pid);
452
+ }
453
+ finalizeStoppedState(state);
454
+ }
455
+
456
+ /** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
457
+ function finalizeStoppedState(state: MachineState): void {
458
+ unregisterSocketMembers(state);
459
+ // Update persisted state
460
+ const current = loadMachine(state.name);
461
+ if (current) {
462
+ current.status = "stopped";
463
+ current.pid = undefined;
464
+ saveMachine(current);
465
+ }
466
+ state.status = "stopped";
467
+ state.pid = undefined;
468
+ }
469
+
470
+ /** The body of `remove()`, for a caller that already holds the machine lock. The lock
471
+ * file goes with the directory. */
472
+ async function removeUnderLock(state: MachineState): Promise<void> {
473
+ if (state.pid && isMachineRunning(state)) {
474
+ await stopQemu(state.pid);
475
+ }
476
+ unregisterSocketMembers(state);
477
+ // Clean up stored instance credentials
478
+ deleteInstanceCredentials(state.name);
479
+ removeState(state.name);
480
+ }
481
+
482
+ /** The body of `clean()`, for a caller that already holds the machine lock. */
483
+ async function cleanUnderLock(state: MachineState): Promise<void> {
484
+ if (state.pid && isMachineRunning(state)) {
485
+ await stopQemu(state.pid);
486
+ }
487
+ // Same invariant as stop()/remove(): a machine that is not running holds no
488
+ // endpoint. clean() stops QEMU directly rather than through stop(), so without
489
+ // this a cleaned machine keeps a pair link occupied and blocks its removal.
490
+ unregisterSocketMembers(state);
491
+ await resetGuestToFactory(state);
492
+
493
+ // Every guest-side credential went with the old disk, so the credential
494
+ // facts in machine.json now describe a machine that no longer exists.
495
+ // Left in place, `resolveAuth()` /
496
+ // `resolveCreds()` prefer `state.user` and authenticate every REST call,
497
+ // exec, and SCP as a deleted user — the confound #79 removes here.
498
+ // Clearing them restores the documented fallback: factory `admin` with an
499
+ // empty password, which is what a fresh CHR image actually answers to
500
+ // (anchored by the "clean() resets disk to factory defaults" integration
501
+ // test). `disableAdmin` goes with them because it is read as a live fact
502
+ // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
503
+ // image has admin enabled again. `licenseLevel` goes with them for the same
504
+ // reason: it is a read-back of the license the erased disk held, not an
505
+ // intent — quickchr never persists the license *input* — so leaving it
506
+ // behind would report a level the fresh image does not have. Provisioning
507
+ // *intent* — packages, deviceMode, secureLogin — is not guest state and
508
+ // survives, and is what the next `start()` re-applies now that the window
509
+ // reopens.
510
+ // (`resetGuestToFactory()` has already dropped the secret-store entry and the
511
+ // managed keypair, which describe the same erased account.)
512
+
513
+ // Update state. `provisioning` and `lastStartedAt` go with the credentials:
514
+ // both describe a guest that no longer exists, and together they are what
515
+ // closes the provisioning window. Leaving them behind is what stopped a cleaned
516
+ // machine from ever being provisioned again — the disk was factory-fresh and
517
+ // `start()` still refused to provision it (#176). `cleanedAt` keeps the
518
+ // forensic clue that the timestamp carried.
519
+ const cleanedAt = new Date().toISOString();
520
+ const current = loadMachine(state.name);
521
+ if (current) {
522
+ current.status = "stopped";
523
+ current.pid = undefined;
524
+ current.user = undefined;
525
+ current.managedSshKey = undefined;
526
+ current.disableAdmin = undefined;
527
+ current.provisioning = undefined;
528
+ current.lastStartedAt = undefined;
529
+ current.licenseLevel = undefined;
530
+ current.cleanedAt = cleanedAt;
531
+ saveMachine(current);
532
+ }
533
+ state.status = "stopped";
534
+ state.pid = undefined;
535
+ state.user = undefined;
536
+ state.managedSshKey = undefined;
537
+ state.disableAdmin = undefined;
538
+ state.provisioning = undefined;
539
+ state.lastStartedAt = undefined;
540
+ state.licenseLevel = undefined;
541
+ state.cleanedAt = cleanedAt;
542
+ }
543
+
319
544
  /** Create a ChrInstance handle from persisted MachineState. */
320
545
  function createInstance(state: MachineState): ChrInstance {
321
546
  const ports = toChrPorts(state.ports);
@@ -404,14 +629,16 @@ function createInstance(state: MachineState): ChrInstance {
404
629
  // keeps the excludePorts fix: excluding "http" while keeping "https" now
405
630
  // resolves rest-api onto the surviving secure port instead of a port that
406
631
  // 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
- );
632
+ const restApi: ServiceEndpoint = isRouterOs6(state.version)
633
+ ? { available: false, unavailableReason: `RouterOS ${state.version} has no REST API — use native-api` }
634
+ : buildHttpService(
635
+ state.ports.https,
636
+ state.ports.http,
637
+ "rest-api",
638
+ { secure: "https", plain: "http" },
639
+ { username: creds.user, password: creds.password, basic, header: auth.header },
640
+ "/rest",
641
+ );
415
642
  const nativeApi = buildHttpService(
416
643
  state.ports["api-ssl"],
417
644
  state.ports.api,
@@ -498,11 +725,11 @@ function createInstance(state: MachineState): ChrInstance {
498
725
  portBase: state.portBase,
499
726
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
500
727
  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
728
 
505
729
  async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
730
+ if (readinessProbeFor(state.version) === "api") {
731
+ return waitForApiReady(ports.api, timeoutMs ?? 120_000, resolveCreds(state), stats);
732
+ }
506
733
  // Use resolved credentials so waitForBoot can validate the response body
507
734
  // on authenticated machines (post-provisioning, post-install reboots).
508
735
  const auth = resolveAuth(state);
@@ -523,101 +750,32 @@ function createInstance(state: MachineState): ChrInstance {
523
750
  },
524
751
 
525
752
  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;
753
+ await withMachineLock(state, "stop", () => stopUnderLock(state));
754
+ },
755
+
756
+ async shutdown(options) {
757
+ const timeoutMs = validateShutdownTimeout(options?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
758
+ return withMachineLock(state, "shutdown", async () => {
759
+ if (!state.pid || !isMachineRunning(state)) {
760
+ finalizeStoppedState(state);
761
+ return { outcome: "already-stopped" };
762
+ }
763
+ const pid = state.pid;
764
+ const result = await shutdownGuest(timeoutMs, async (requestTimeoutMs) => {
765
+ // Rebuild after the locked refresh: a waiting handle may predate a relaunch.
766
+ return createInstance(state).exec("/system/shutdown", { via: "rest", timeout: requestTimeoutMs });
767
+ }, () => isMachineRunning(state), () => stopQemu(pid));
768
+ finalizeStoppedState(state);
769
+ return result;
770
+ });
539
771
  },
540
772
 
541
773
  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);
774
+ await withMachineLock(state, "remove", () => removeUnderLock(state));
549
775
  },
550
776
 
551
777
  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;
778
+ await withMachineLock(state, "clean", () => cleanUnderLock(state));
621
779
  },
622
780
 
623
781
  async monitor(command: string): Promise<string> {
@@ -633,6 +791,7 @@ function createInstance(state: MachineState): ChrInstance {
633
791
  },
634
792
 
635
793
  async rest(path: string, opts?: RequestInit): Promise<unknown> {
794
+ assertRestAvailable(state, "rest()");
636
795
  const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
637
796
 
638
797
  // Retry on ECONNRESET — RouterOS can transiently reset connections in the
@@ -722,6 +881,9 @@ function createInstance(state: MachineState): ChrInstance {
722
881
  `exec transport "${via}" is not yet implemented`,
723
882
  );
724
883
  }
884
+ // Before the auto→console fallback below: that fallback exists for an
885
+ // unreachable REST endpoint, not for one that cannot exist.
886
+ assertRestAvailable(state, "exec");
725
887
  const auth = resolveAuth(state, opts?.user, opts?.password);
726
888
  if (via === "rest") {
727
889
  return restExecute(restUrl, auth, command, opts);
@@ -769,15 +931,62 @@ function createInstance(state: MachineState): ChrInstance {
769
931
  const log = logger ?? createLogger();
770
932
  const resolved = resolveDeviceModeOptions(options);
771
933
  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);
934
+ if (!shouldApplyDeviceMode(resolved)) {
935
+ throw new QuickCHRError(
936
+ "INVALID_ARGUMENT",
937
+ `Nothing to set: device-mode options for "${state.name}" resolved to no change ` +
938
+ "(mode=skip disables device-mode entirely; pass a mode or a feature to enable/disable).",
939
+ );
940
+ }
941
+ // The same machine lock every other relaunch takes. This one is a relaunch:
942
+ // it terminates QEMU, spawns a replacement and rewrites `machine.json`, so a
943
+ // concurrent `start` would otherwise spawn a second QEMU into the power-cycle
944
+ // window and both would persist over each other. Fail-fast like a second
945
+ // `start`: waiting would only power-cycle the machine twice.
946
+ const lock = acquireLockNow(machineLockPath(state.machineDir), "device-mode", machineLockOptions(state.name, state.machineDir));
947
+ try {
948
+ assertSameMachine(state);
949
+ // Re-read under the lock before anything reads `state`. This handle's
950
+ // snapshot is taken once, at `createInstance`, and a library consumer can
951
+ // hold it across another process's stop/start — after which `state.pid`
952
+ // names a process that has exited (a false MACHINE_STOPPED) or, worse, one
953
+ // the OS has since reused, which `hardRebootMachine()` would then
954
+ // terminate. `state.provisioning` and `state.deviceMode` go stale the same
955
+ // way, and both feed `appliedDeviceModeRecord()`.
956
+ refreshMachineState(state);
957
+ // Inside the lock: the applicability check has to hold for the operation,
958
+ // not merely at the moment it was made.
959
+ assertDeviceModeApplicable(state);
960
+ const launchConfig = await buildLaunchConfigFromState(state);
961
+ // Post-boot auth, not factory admin: a machine provisioned with
962
+ // `--disable-admin` answers 401 to `admin:`, and those are exactly the
963
+ // machines this route exists for.
964
+ await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log, resolveAuth(state).header);
965
+
966
+ // Persist what is now true of the guest: the applied selection folded into
967
+ // what was already *applied*, not the request on its own. Device-mode
968
+ // updates are cumulative — only the settings a request names move — so
969
+ // overwriting would leave `machine.json` describing a machine that does
970
+ // not exist.
971
+ //
972
+ // `appliedDeviceModeRecord` gates the carry-forward on the step record, for
973
+ // the reason #176 keeps running into: desired config is not evidence that
974
+ // a step ran.
975
+ const deviceMode = appliedDeviceModeRecord(state, resolved);
976
+ const current = loadMachine(state.name);
977
+ if (current) {
978
+ current.deviceMode = deviceMode;
979
+ saveMachine(current);
980
+ }
981
+ state.deviceMode = deviceMode;
982
+ // This is quickchr applying a provisioning step, so it belongs in the
983
+ // record the window reads (#176). It does not reopen the window — it adds
984
+ // the one step that just ran, so a later `start` passing the same
985
+ // device-mode is recognised as already applied instead of refused.
986
+ recordProvisioningStep(state, "deviceMode");
987
+ } finally {
988
+ lock.release();
779
989
  }
780
- state.deviceMode = options;
781
990
  },
782
991
 
783
992
  async availablePackages(): Promise<string[]> {
@@ -789,21 +998,28 @@ function createInstance(state: MachineState): ChrInstance {
789
998
  const names = typeof packages === "string" ? [packages] : packages;
790
999
  if (names.length === 0) return [];
791
1000
 
792
- const extractDir = await downloadPackages(state.version, state.arch);
1001
+ // A booted machine no longer holds its package cache in use, so pin it until the
1002
+ // upload is done: a concurrent eviction must not remove it mid-read.
1003
+ const pin = pinCacheEntries(`install packages on ${state.name}`, [`packages-${state.arch}-${state.version}`]);
793
1004
  const packagePaths: string[] = [];
794
1005
  const installed: string[] = [];
795
- for (const pkg of names) {
796
- const pkgPath = findPackageFile(extractDir, pkg);
797
- if (!pkgPath) {
798
- console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
799
- continue;
1006
+ try {
1007
+ const extractDir = await downloadPackages(state.version, state.arch);
1008
+ for (const pkg of names) {
1009
+ const pkgPath = findPackageFile(extractDir, pkg);
1010
+ if (!pkgPath) {
1011
+ console.warn(`Package "${pkg}" not found in all_packages for ${state.version} (${state.arch})`);
1012
+ continue;
1013
+ }
1014
+ packagePaths.push(pkgPath);
1015
+ installed.push(pkg);
800
1016
  }
801
- packagePaths.push(pkgPath);
802
- installed.push(pkg);
803
- }
804
- if (packagePaths.length === 0) return [];
1017
+ if (packagePaths.length === 0) return [];
805
1018
 
806
- await uploadPackages(packagePaths, ports.ssh);
1019
+ await uploadPackages(packagePaths, ports.ssh);
1020
+ } finally {
1021
+ pin.release();
1022
+ }
807
1023
 
808
1024
  // Reboot to activate packages
809
1025
  let rebootAuth: string;
@@ -864,8 +1080,10 @@ function createInstance(state: MachineState): ChrInstance {
864
1080
  },
865
1081
 
866
1082
  async destroy(): Promise<void> {
867
- await this.stop();
868
- await this.remove();
1083
+ await withMachineLock(state, "remove", async () => {
1084
+ await stopUnderLock(state);
1085
+ await removeUnderLock(state);
1086
+ });
869
1087
  },
870
1088
 
871
1089
  async subprocessEnv(): Promise<Record<string, string>> {
@@ -905,7 +1123,7 @@ function createInstance(state: MachineState): ChrInstance {
905
1123
 
906
1124
  snapshot: {
907
1125
  async list(): Promise<SnapshotInfo[]> {
908
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1126
+ const format = bootDiskFormatOf(state);
909
1127
  if (format !== "qcow2") return [];
910
1128
 
911
1129
  // Try monitor first (works on running machines, gives live state)
@@ -917,7 +1135,7 @@ function createInstance(state: MachineState): ChrInstance {
917
1135
  }
918
1136
 
919
1137
  // Fall back to qemu-img info (works on stopped machines too)
920
- const bootPath = join(state.machineDir, "boot.qcow2");
1138
+ const bootPath = bootDiskPath(state.machineDir, "qcow2");
921
1139
  if (existsSync(bootPath)) {
922
1140
  return listSnapshots(bootPath);
923
1141
  }
@@ -925,7 +1143,7 @@ function createInstance(state: MachineState): ChrInstance {
925
1143
  },
926
1144
 
927
1145
  async save(name?: string): Promise<SnapshotInfo> {
928
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1146
+ const format = bootDiskFormatOf(state);
929
1147
  if (format !== "qcow2") {
930
1148
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk. Recreate with bootDiskFormat: \"qcow2\".");
931
1149
  }
@@ -962,7 +1180,7 @@ function createInstance(state: MachineState): ChrInstance {
962
1180
  },
963
1181
 
964
1182
  async load(name: string): Promise<void> {
965
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1183
+ const format = bootDiskFormatOf(state);
966
1184
  if (format !== "qcow2") {
967
1185
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
968
1186
  }
@@ -988,7 +1206,7 @@ function createInstance(state: MachineState): ChrInstance {
988
1206
  },
989
1207
 
990
1208
  async delete(name: string): Promise<void> {
991
- const format = state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
1209
+ const format = bootDiskFormatOf(state);
992
1210
  if (format !== "qcow2") {
993
1211
  throw new QuickCHRError("STATE_ERROR", "Snapshots require a qcow2 boot disk.");
994
1212
  }
@@ -1005,44 +1223,6 @@ function createInstance(state: MachineState): ChrInstance {
1005
1223
  };
1006
1224
  }
1007
1225
 
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
1226
  /** Resolve the host architecture to a CHR architecture. */
1047
1227
  function hostArchToChr(): Arch {
1048
1228
  const arch = process.arch;
@@ -1222,28 +1402,122 @@ export async function captureRunningFailure(
1222
1402
  });
1223
1403
  }
1224
1404
 
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(
1405
+ /** Cleanup intent is captured under the lifecycle lock, before launch mutates state. */
1406
+ export type BootFailureCleanup =
1407
+ | { kind: "remove-new" }
1408
+ | { kind: "reset-first-boot"; restore: MachineState }
1409
+ | { kind: "preserve-existing" };
1410
+
1411
+ /** Stop the failed guest, then apply the launch's cleanup intent.
1412
+ * Diagnostics have already been captured outside the machine directory. */
1413
+ export async function cleanupAfterBootFailure(
1237
1414
  instance: ChrInstance,
1238
1415
  name: string,
1239
1416
  failure: BootFailureReport,
1417
+ cleanup: BootFailureCleanup,
1240
1418
  ): Promise<string> {
1241
- try { await instance.stop(); } catch { /* ignore */ }
1419
+ const quoted = shellQuote(name);
1420
+ // Called from inside a start or relaunch that holds the machine lock.
1421
+ try {
1422
+ await stopUnderLock(instance.state);
1423
+ } catch (err) {
1424
+ const reason = err instanceof Error ? err.message : String(err);
1425
+ return ` Machine "${name}" was retained, but stopping QEMU failed (${reason}) — retry with \`quickchr stop ${quoted}\` before starting or removing it.`;
1426
+ }
1427
+ if (cleanup.kind === "preserve-existing") {
1428
+ return ` Machine "${name}" was stopped and preserved with its disks and configuration — ` +
1429
+ `retry with \`quickchr start ${quoted}\`, or explicitly remove it with \`quickchr remove ${quoted}\`.`;
1430
+ }
1431
+ const restore = cleanup.kind === "reset-first-boot" ? cleanup.restore : undefined;
1242
1432
  if (failure.preserved) {
1243
- return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
1433
+ return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — ` +
1434
+ (restore ? `reset it with \`quickchr clean ${quoted}\` and start it again, or ` : "") +
1435
+ `remove it with \`quickchr remove ${quoted}\`.`;
1244
1436
  }
1245
- try { await instance.remove(); } catch { /* ignore */ }
1246
- return ` Machine "${name}" has been cleaned up automatically.`;
1437
+ // An add()-created machine on its first boot: the same reset a failed provisioning
1438
+ // run gets (#187), so the retry is `start` again rather than `add` + `start`. The
1439
+ // guest may have written to its disk before stalling, so the disk goes back to the
1440
+ // factory image along with the record — the window is open again, and an open
1441
+ // window promises a factory disk.
1442
+ if (restore) {
1443
+ try {
1444
+ await resetGuestToFactory(restore);
1445
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1446
+ refreshMachineState(instance.state);
1447
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — ` +
1448
+ `retry with \`quickchr start ${quoted}\`. Set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine as it is for diagnosis.`;
1449
+ } catch (resetErr) {
1450
+ const reason = resetErr instanceof Error ? resetErr.message : String(resetErr);
1451
+ return ` Resetting machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1452
+ }
1453
+ }
1454
+ try { await removeUnderLock(instance.state); } catch { /* ignore */ }
1455
+ return ` Machine "${name}" has been cleaned up automatically — set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine for diagnosis.`;
1456
+ }
1457
+
1458
+ /** Undo a first boot whose provisioning threw, so that retrying is re-running the same
1459
+ * command (#176).
1460
+ *
1461
+ * Provisioning is transactional — you get the machine you asked for or you don't —
1462
+ * and the throw used to break that: the guest was left running and half-provisioned,
1463
+ * with `lastStartedAt` set, so the window was closed and the retry a user reaches for
1464
+ * was refused. Resuming from the steps that landed is not the fix. A step can throw
1465
+ * half-applied (the login group creates a user and then disables `admin` in one call),
1466
+ * and quickchr cannot tell how far it got without reading the guest. So the rollback
1467
+ * reverts the whole run, and the retry starts again from a factory disk:
1468
+ *
1469
+ * - **`restore` given** — an `add()`-created machine on its first boot. The disk is
1470
+ * reset to the factory image and `machine.json` goes back to the record `add()`
1471
+ * wrote, window open and intent intact, so `quickchr start <name>` tries again.
1472
+ * - **no `restore`** — `start()` created the machine in this call, so it is removed,
1473
+ * exactly as a boot timeout removes it.
1474
+ *
1475
+ * `QUICKCHR_PRESERVE_ON_FAILURE=1` keeps the half-provisioned machine for inspection,
1476
+ * as it does for a boot failure. QEMU is stopped either way.
1477
+ *
1478
+ * Returns the sentence to append to the error. A rollback that fails is reported in
1479
+ * that sentence, never swallowed — the provisioning error stays the primary one. */
1480
+ export async function rollbackFailedProvisioning(
1481
+ instance: ChrInstance,
1482
+ restore: MachineState | undefined,
1483
+ ): Promise<string> {
1484
+ const name = instance.state.name;
1485
+ const quoted = shellQuote(name);
1486
+ // Called from inside a start or relaunch that holds the machine lock.
1487
+ try {
1488
+ await stopUnderLock(instance.state);
1489
+ if (preserveOnFailure()) {
1490
+ 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}\`.`;
1491
+ }
1492
+ if (!restore) {
1493
+ await removeUnderLock(instance.state);
1494
+ return ` Machine "${name}" has been removed — re-run the same command to retry.`;
1495
+ }
1496
+ await resetGuestToFactory(restore);
1497
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1498
+ refreshMachineState(instance.state);
1499
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — re-run the same start command to retry.`;
1500
+ } catch (rollbackErr) {
1501
+ const reason = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1502
+ return ` Rolling back machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1503
+ }
1504
+ }
1505
+
1506
+ /** Append the rollback outcome to a provisioning error and hand it back for rethrow.
1507
+ * The error keeps its own type, code and stack: the rollback is context, not the
1508
+ * failure. */
1509
+ export async function failProvisioning(
1510
+ err: unknown,
1511
+ instance: ChrInstance,
1512
+ restore: MachineState | undefined,
1513
+ ): Promise<unknown> {
1514
+ // Its own line: the provisioning error often ends in a RouterOS JSON body.
1515
+ const outcome = (await rollbackFailedProvisioning(instance, restore)).trim();
1516
+ if (err instanceof Error) {
1517
+ err.message += `\n${outcome}`;
1518
+ return err;
1519
+ }
1520
+ return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
1247
1521
  }
1248
1522
 
1249
1523
  /** Wait for CHR to boot with periodic progress status updates every 20s. */
@@ -1314,7 +1588,8 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1314
1588
  state.machineDir,
1315
1589
  state.bootSize,
1316
1590
  state.extraDisks,
1317
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
1591
+ bootDiskFormatOf(state),
1592
+ { booted: true },
1318
1593
  );
1319
1594
  const platform = await detectPlatform();
1320
1595
  const hostfwd = buildHostfwdString(state.ports);
@@ -1337,26 +1612,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1337
1612
  };
1338
1613
  }
1339
1614
 
1615
+ /** Persist what provisioning landed on the current disk, in state and on disk.
1616
+ * A run with nothing to do still records — an empty `steps` says "provisioning ran
1617
+ * and had nothing to apply", which is a different fact from never having run. */
1618
+ function recordProvisioning(state: MachineState, steps: ProvisioningStep[]): void {
1619
+ const record = { at: new Date().toISOString(), steps };
1620
+ const current = loadMachine(state.name);
1621
+ if (current) {
1622
+ current.provisioning = record;
1623
+ saveMachine(current);
1624
+ }
1625
+ state.provisioning = record;
1626
+ }
1627
+
1628
+ /** Re-read `machine.json` into an existing state object, in place.
1629
+ *
1630
+ * **In place** because `createInstance` closes over this object and exposes it as
1631
+ * `instance.state`: rebinding a local would leave the caller's view stale, and every
1632
+ * other method on the handle would keep reading the old snapshot.
1633
+ *
1634
+ * Keys the reloaded record does not have are deleted rather than left behind.
1635
+ * `clean()` removes `provisioning`, `user` and `disableAdmin` precisely because they
1636
+ * stopped being true, so a leftover would be read as fact by the next caller —
1637
+ * `appliedDeviceModeRecord()` reads exactly one of them. A plain `Object.assign`
1638
+ * cannot express a field that went away. */
1639
+ export function refreshMachineState(state: MachineState): void {
1640
+ const fresh = loadMachine(state.name);
1641
+ if (!fresh) return;
1642
+ const mutable = state as unknown as Record<string, unknown>;
1643
+ for (const key of Object.keys(mutable)) {
1644
+ if (!(key in fresh)) delete mutable[key];
1645
+ }
1646
+ Object.assign(state, fresh);
1647
+ }
1648
+
1649
+ /** Add one step to the provisioning record, keeping whatever is already there.
1650
+ *
1651
+ * `recordProvisioning()` stamps a whole first-boot run; this is for a step applied on
1652
+ * its own afterwards — `setDeviceMode()` today, `installPackage()` when #24 lands.
1653
+ * `steps` accumulates and `at` moves to now, which is what `ProvisioningRecord`
1654
+ * promises: the last time quickchr applied provisioning to this disk.
1655
+ *
1656
+ * It cannot reopen or close a window by accident. The only caller requires a running
1657
+ * machine, and a running machine has `lastStartedAt`, so the window was already shut
1658
+ * before this wrote anything. */
1659
+ function recordProvisioningStep(state: MachineState, step: ProvisioningStep): void {
1660
+ const steps = [...new Set([...(state.provisioning?.steps ?? []), step])];
1661
+ const record = { at: new Date().toISOString(), steps };
1662
+ const current = loadMachine(state.name);
1663
+ if (current) {
1664
+ current.provisioning = record;
1665
+ saveMachine(current);
1666
+ }
1667
+ state.provisioning = record;
1668
+ }
1669
+
1670
+ /** Preconditions a post-boot device-mode change inherits from provisioning.
1671
+ *
1672
+ * Both were undocumented and both fail confusingly when unchecked (#176):
1673
+ * `applyDeviceMode()` polls `waitForDeviceModeApi` before it does anything, so a
1674
+ * stopped machine spends 60 s to report a timeout, and a machine with no user-mode
1675
+ * NIC has no localhost route to REST at all and never recovers.
1676
+ *
1677
+ * This is the same guard `start()` applies to provisioning, moved to the one step
1678
+ * that may also run afterwards. */
1679
+ function assertDeviceModeApplicable(state: MachineState): void {
1680
+ if (!isMachineRunning(state)) {
1681
+ throw new QuickCHRError(
1682
+ "MACHINE_STOPPED",
1683
+ `Device-mode is applied through the guest's REST API, so "${state.name}" has to be running. ` +
1684
+ `Start it with 'quickchr start ${shellQuote(state.name)}', then set device-mode.`,
1685
+ );
1686
+ }
1687
+ if (!hasUserModeNetwork(state.networks)) {
1688
+ throw new QuickCHRError(
1689
+ "NETWORK_UNAVAILABLE",
1690
+ `Device-mode is applied over localhost REST, which needs a user-mode network interface — ` +
1691
+ `"${state.name}" has none, so quickchr cannot reach its REST API. ` +
1692
+ "Recreate the machine with a user-mode NIC alongside its other networks.",
1693
+ );
1694
+ }
1695
+ }
1696
+
1340
1697
  /** 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. */
1698
+ * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
1699
+ *
1700
+ * `authHeader` is the caller's, because the two callers genuinely differ and the
1701
+ * difference is not cosmetic. During first-boot provisioning device-mode is step 2 and
1702
+ * the user step is step 4, so factory `admin:` is the *only* credential that exists
1703
+ * yet — `state.user` is already populated at that point but names an account nobody
1704
+ * has created, so resolving it here would 401 the shipped path. Post-boot the reverse
1705
+ * holds: a machine provisioned with `--disable-admin` answers 401 to factory admin,
1706
+ * which is what made `quickchr set <name> --device-mode` fail on exactly the machines
1707
+ * the route was written for. Same field, opposite answers, decided by the caller
1708
+ * rather than guessed here. */
1342
1709
  async function applyDeviceMode(
1343
1710
  instance: ChrInstance,
1344
1711
  machineState: MachineState,
1345
1712
  resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
1346
1713
  launchConfig: QemuLaunchConfig,
1347
1714
  log: ProgressLogger,
1715
+ authHeader: string,
1348
1716
  ): Promise<void> {
1349
1717
  const httpPort = toChrPorts(machineState.ports).http;
1350
1718
  const accel = await detectAccel(machineState.arch);
1351
1719
  const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
1352
- await waitForDeviceModeApi(httpPort, 60_000);
1720
+ await waitForDeviceModeApi(httpPort, 60_000, authHeader);
1353
1721
  log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
1354
1722
 
1355
1723
  let alreadyActive = false;
1356
1724
  try {
1357
- const beforeMode = await readDeviceMode(httpPort);
1725
+ const beforeMode = await readDeviceMode(httpPort, authHeader);
1358
1726
  log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
1359
1727
  alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
1728
+ // Say what the power cycle is buying before spending it. `mode` moves as a
1729
+ // side-effect of enabling a feature — `--device-mode-enable container` resolves
1730
+ // `auto` to `rose` — and a change nobody asked for should at least be announced
1731
+ // (#176).
1732
+ for (const change of describeDeviceModeChange(resolvedDeviceMode, beforeMode)) {
1733
+ log.status(` ${change}`);
1734
+ }
1360
1735
  } catch { /* CHR unexpectedly unreachable — proceed with update */ }
1361
1736
 
1362
1737
  if (alreadyActive) {
@@ -1369,7 +1744,7 @@ async function applyDeviceMode(
1369
1744
  let requiresPowerCycle = false;
1370
1745
 
1371
1746
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1372
- const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
1747
+ const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
1373
1748
  // RouterOS blocks this connection while waiting for hard power-cycle confirmation.
1374
1749
  // We race against 2s: if still pending at 2s, RouterOS has entered blocking state
1375
1750
  // and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
@@ -1399,6 +1774,22 @@ async function applyDeviceMode(
1399
1774
  }
1400
1775
 
1401
1776
  log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
1777
+
1778
+ // A rejection is an answer, not a race — say what RouterOS said and stop.
1779
+ // Retrying here used to cost five POSTs and ~33 s and then report a *mismatch*
1780
+ // (`bogus-feature: expected=yes, actual=(missing)`) while RouterOS had replied
1781
+ // `HTTP 400 — unknown parameter bogus-feature` on the first attempt. RouterOS
1782
+ // also counts device-mode update attempts (`attempt-count` on
1783
+ // `/system/device-mode`), so the retries spend a budget that is not ours to
1784
+ // spend. Only a 2xx that came back before activation is worth trying again.
1785
+ const { status, body } = outcome.response;
1786
+ if (status < 200 || status >= 300) {
1787
+ throw new QuickCHRError(
1788
+ "PROCESS_FAILED",
1789
+ `Device-mode update rejected by RouterOS: HTTP ${status} — ${body.trim() || "(empty body)"}`,
1790
+ );
1791
+ }
1792
+
1402
1793
  await Bun.sleep(5000);
1403
1794
 
1404
1795
  let routerOsOffline = false;
@@ -1415,10 +1806,10 @@ async function applyDeviceMode(
1415
1806
  if (!rebooted) {
1416
1807
  throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
1417
1808
  }
1418
- await waitForDeviceModeApi(httpPort, bootTimeout);
1809
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1419
1810
  }
1420
1811
 
1421
- const actualNow = await readDeviceMode(httpPort);
1812
+ const actualNow = await readDeviceMode(httpPort, authHeader);
1422
1813
  log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
1423
1814
  const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
1424
1815
  if (immediateVerification.ok) {
@@ -1448,10 +1839,10 @@ async function applyDeviceMode(
1448
1839
  `Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
1449
1840
  );
1450
1841
  }
1451
- await waitForDeviceModeApi(httpPort, bootTimeout);
1842
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1452
1843
  }
1453
1844
 
1454
- const actual = await readDeviceMode(httpPort);
1845
+ const actual = await readDeviceMode(httpPort, authHeader);
1455
1846
  log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
1456
1847
  const verification = verifyDeviceMode(resolvedDeviceMode, actual);
1457
1848
  if (!verification.ok) {
@@ -1463,6 +1854,112 @@ async function applyDeviceMode(
1463
1854
  log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
1464
1855
  }
1465
1856
 
1857
+ /** How long a creation waits for another to finish choosing its port block. The
1858
+ * critical section is a handful of loopback connect probes, so this is headroom for a
1859
+ * busy host, not an expected wait. */
1860
+ const ALLOCATION_LOCK_WAIT_MS = 60_000;
1861
+
1862
+ /** An explicit `portBase` is the caller's choice and is never moved: one that overlaps
1863
+ * another machine's block — persisted or still being created — is a `PORT_CONFLICT`
1864
+ * here rather than a `SPAWN_FAILED` from QEMU's bind later. */
1865
+ function assertPortBaseFree(
1866
+ portBase: number,
1867
+ name: string,
1868
+ machines: MachineState[],
1869
+ reserved: Array<{ name: string; portBase: number }>,
1870
+ ): void {
1871
+ const others = [
1872
+ ...machines.filter((m) => m.name !== name).map((m) => ({ name: m.name, portBase: m.portBase })),
1873
+ ...reserved,
1874
+ ];
1875
+ const clash = others.find((o) => Math.abs(o.portBase - portBase) < PORTS_PER_BLOCK);
1876
+ if (clash) {
1877
+ throw new QuickCHRError(
1878
+ "PORT_CONFLICT",
1879
+ `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.`,
1880
+ );
1881
+ }
1882
+ }
1883
+
1884
+ /** Choose a new machine's port block and NIC MACs, avoiding every persisted machine
1885
+ * and every creation still in flight. Choosing is not claiming: only
1886
+ * `claimNewMachine()` makes the result safe from a concurrent creation. */
1887
+ async function pickNewMachineResources(
1888
+ name: string,
1889
+ opts: StartOptions,
1890
+ ): Promise<{ portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1891
+ const machines = loadAllMachines();
1892
+ const reserved = listReservations(name);
1893
+ if (opts.portBase) assertPortBaseFree(opts.portBase, name, machines, reserved);
1894
+ const usedBases = [...getUsedPortBases(), ...reserved.map((r) => r.portBase)];
1895
+ const portBase = opts.portBase || await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1896
+ validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, machines, name);
1897
+ const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
1898
+
1899
+ const usedMacs = getUsedMacs();
1900
+ for (const r of reserved) for (const mac of r.macs) usedMacs.add(mac);
1901
+ const networks = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), usedMacs);
1902
+ return { portBase, ports, networks };
1903
+ }
1904
+
1905
+ /** Choose a new machine's resources and take its machine lock with them recorded in
1906
+ * it, all while holding the data-dir allocation lock (#140).
1907
+ *
1908
+ * Without this, two concurrent creations both read the same machine list, both probe
1909
+ * the same free ports, and both pick the same block; one QEMU then fails its
1910
+ * `hostfwd` bind. Nothing persists the choice until `saveMachine()`, which for
1911
+ * `start()` is after the download and the spawn, so the choice itself has to be
1912
+ * visible: the machine lock carries it as a reservation, `listReservations()` reads
1913
+ * it, and it ends when the lock is released — by which time `machine.json` holds it,
1914
+ * or the creation failed and there is nothing left to hold. A crashed creator's
1915
+ * reservation dies with its pid.
1916
+ *
1917
+ * The machine lock is fail-fast. A live holder is another create of the same name. */
1918
+ export async function claimNewMachine(
1919
+ name: string,
1920
+ op: Extract<LockOperation, "add" | "start" | "foreground">,
1921
+ opts: StartOptions,
1922
+ ): Promise<{ lock: HeldLock; portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1923
+ ensureDir(getDataDir());
1924
+ const allocation = await acquireLock(allocationLockPath(), "allocate", {
1925
+ waitMs: ALLOCATION_LOCK_WAIT_MS,
1926
+ subject: "The port allocator",
1927
+ });
1928
+ try {
1929
+ const picked = await pickNewMachineResources(name, opts);
1930
+ const machineDir = getMachineDir(name);
1931
+ const lockOpts = {
1932
+ ...machineLockOptions(name, machineDir),
1933
+ reserve: {
1934
+ portBase: picked.portBase,
1935
+ macs: picked.networks.flatMap((n) => (n.mac ? [n.mac] : [])),
1936
+ },
1937
+ };
1938
+ // The lock lives in the directory, so the directory comes first. A refusal here
1939
+ // means a live holder owns the directory — a create of the same name, or a
1940
+ // remove — and it, not this call, cleans it up. A remove renames the directory
1941
+ // away in one step; if that lands between the two lines, make a fresh one.
1942
+ let lock: HeldLock | undefined;
1943
+ for (let tries = 0; !lock; tries++) {
1944
+ ensureDir(machineDir);
1945
+ try {
1946
+ lock = acquireLockNow(machineLockPath(machineDir), op, lockOpts);
1947
+ } catch (e) {
1948
+ if (!(e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") || tries >= 2) throw e;
1949
+ }
1950
+ }
1951
+ // Another create of this name may have finished between the caller's existence
1952
+ // check and this lock.
1953
+ if (existsSync(join(machineDir, "machine.json"))) {
1954
+ lock.release();
1955
+ throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" was created by another process while this one was starting.`);
1956
+ }
1957
+ return { lock, ...picked };
1958
+ } finally {
1959
+ allocation.release();
1960
+ }
1961
+ }
1962
+
1466
1963
  /**
1467
1964
  * Main entry point for managing MikroTik CHR virtual machines via QEMU.
1468
1965
  *
@@ -1488,6 +1985,7 @@ export class QuickCHR {
1488
1985
  // add() always creates, so the full name rules apply before any I/O.
1489
1986
  if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
1490
1987
 
1988
+ warnVersionWithChannel(opts, createLogger(opts.onProgress));
1491
1989
  let version: string;
1492
1990
  if (opts.version) {
1493
1991
  if (!isValidVersion(opts.version)) {
@@ -1500,13 +1998,10 @@ export class QuickCHR {
1500
1998
  }
1501
1999
 
1502
2000
  const arch: Arch = resolveArch(opts.arch);
1503
- requireQemu(arch);
1504
- if (arch === "arm64") requireFirmware();
1505
- const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
2001
+ assertRouterOs6Arch(version, arch);
1506
2002
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
1507
2003
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1508
-
1509
- if (hasProvisioningMutations({
2004
+ const mutations = {
1510
2005
  installAllPackages: opts.installAllPackages,
1511
2006
  packages: opts.packages,
1512
2007
  hasDeviceModeProvisioning,
@@ -1514,17 +2009,15 @@ export class QuickCHR {
1514
2009
  disableAdmin: opts.disableAdmin,
1515
2010
  license: opts.license,
1516
2011
  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
- }));
2012
+ };
2013
+ // Version refusals before host checks: a request that can never succeed should
2014
+ // say why, not first ask for QEMU to be installed.
2015
+ if (hasProvisioningMutations(mutations)) {
2016
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
1527
2017
  }
2018
+ requireQemu(arch);
2019
+ if (arch === "arm64") requireFirmware();
2020
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1528
2021
 
1529
2022
  const existingNames = listMachineNames();
1530
2023
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
@@ -1540,24 +2033,29 @@ export class QuickCHR {
1540
2033
  : new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
1541
2034
  }
1542
2035
 
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
2036
  const machineDir = getMachineDir(name);
1549
- assertSufficientQuickchrStorage(`prepare CHR ${name}`);
1550
- ensureDir(machineDir);
1551
- const lockPath = join(machineDir, ".start-lock");
1552
- acquireLock(lockPath);
2037
+ // Held until machine.json is saved: it reserves the boot disk's bytes and pins the
2038
+ // image against a concurrent eviction (storage-lease.ts).
2039
+ const storageLease = admitStorage(
2040
+ `prepare CHR ${name}`,
2041
+ estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
2042
+ );
2043
+ let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
2044
+ try {
2045
+ claimed = await claimNewMachine(name, "add", opts);
2046
+ } catch (err) {
2047
+ storageLease.release();
2048
+ throw err;
2049
+ }
2050
+ const { lock, portBase, ports, networks } = claimed;
1553
2051
  try {
1554
2052
  const cachedImg = await ensureCachedImage(version, arch);
1555
- copyImageToMachine(cachedImg, machineDir);
1556
2053
  const diskArtifacts = await ensureConfiguredDisks(
1557
2054
  machineDir,
1558
2055
  diskOpts.bootSize,
1559
2056
  diskOpts.extraDisks,
1560
2057
  diskOpts.bootDiskFormat,
2058
+ { booted: false, factoryImage: async () => cachedImg },
1561
2059
  );
1562
2060
 
1563
2061
  const state: MachineState = {
@@ -1566,7 +2064,7 @@ export class QuickCHR {
1566
2064
  arch,
1567
2065
  cpu: opts.cpu ?? 1,
1568
2066
  mem: defaultMem(arch, opts.mem),
1569
- networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
2067
+ networks,
1570
2068
  ports,
1571
2069
  packages: opts.packages ?? [],
1572
2070
  installAllPackages: opts.installAllPackages,
@@ -1597,11 +2095,12 @@ export class QuickCHR {
1597
2095
  // directory it was supposed to clean up. Nothing runs after saveMachine()
1598
2096
  // but `return state`, so a readable file here means the create succeeded.
1599
2097
  if (isOrphanMachineDir(name)) {
1600
- try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
2098
+ try { removeState(name); } catch { /* best effort */ }
1601
2099
  }
1602
2100
  throw err;
1603
2101
  } finally {
1604
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2102
+ lock.release();
2103
+ storageLease.release();
1605
2104
  }
1606
2105
  }
1607
2106
 
@@ -1609,7 +2108,9 @@ export class QuickCHR {
1609
2108
  * Start a new or existing CHR instance.
1610
2109
  *
1611
2110
  * The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
1612
- * device-mode, user) has completed before this promise resolves.
2111
+ * device-mode, user) has completed before this promise resolves. Two exceptions:
2112
+ * `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
2113
+ * a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
1613
2114
  * Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
1614
2115
  * stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
1615
2116
  * apply when creating a new machine and require `qemu-img` on the host.
@@ -1623,6 +2124,13 @@ export class QuickCHR {
1623
2124
  throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
1624
2125
  }
1625
2126
 
2127
+ if (opts.wait === false && opts.background === false) {
2128
+ throw new QuickCHRError(
2129
+ "INVALID_ARGUMENT",
2130
+ "--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.",
2131
+ );
2132
+ }
2133
+
1626
2134
  const logger = createLogger(opts.onProgress);
1627
2135
 
1628
2136
  // Before any download or disk work: a named socket whose two ends are already
@@ -1638,6 +2146,7 @@ export class QuickCHR {
1638
2146
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1639
2147
 
1640
2148
  // Resolve version
2149
+ warnVersionWithChannel(opts, logger);
1641
2150
  let version: string;
1642
2151
  if (opts.version) {
1643
2152
  if ((CHANNELS as readonly string[]).includes(opts.version)) {
@@ -1655,7 +2164,7 @@ export class QuickCHR {
1655
2164
  version = await resolveVersion(channel);
1656
2165
  }
1657
2166
 
1658
- if (hasProvisioningMutations({
2167
+ const mutations = {
1659
2168
  installAllPackages: opts.installAllPackages,
1660
2169
  packages: opts.packages,
1661
2170
  hasDeviceModeProvisioning,
@@ -1663,29 +2172,20 @@ export class QuickCHR {
1663
2172
  disableAdmin: opts.disableAdmin,
1664
2173
  license: opts.license,
1665
2174
  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
- }));
2175
+ };
2176
+ if (hasProvisioningMutations(mutations)) {
2177
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
2178
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
1676
2179
  }
1677
2180
 
1678
2181
  // Resolve architecture
1679
2182
  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
- }
2183
+ // Host prerequisites are checked just before each launch below, after every
2184
+ // refusal: a request that cannot succeed says why before asking for QEMU.
2185
+ const requireHostFor = (target: Arch) => {
2186
+ requireQemu(target);
2187
+ if (target === "arm64") requireFirmware();
2188
+ };
1689
2189
 
1690
2190
  // Resolve name
1691
2191
  const existingNames = listMachineNames();
@@ -1696,20 +2196,45 @@ export class QuickCHR {
1696
2196
  // Check if machine already exists
1697
2197
  const existing = loadMachine(name);
1698
2198
  if (existing) {
2199
+ // Both paths below re-launch a guest quickchr did not just create, so neither
2200
+ // can run provisioning. Refuse before either of them: dropping the options in
2201
+ // silence is what #176 removes, and the running-machine path is the quieter of
2202
+ // the two — it returns a handle without so much as a boot.
2203
+ const request = {
2204
+ installAllPackages: opts.installAllPackages,
2205
+ packages: opts.packages,
2206
+ deviceMode: opts.deviceMode,
2207
+ user: opts.user,
2208
+ disableAdmin: opts.disableAdmin,
2209
+ license: opts.license,
2210
+ secureLogin: opts.secureLogin,
2211
+ };
2212
+ // Version before window: on a version that can never be provisioned, the
2213
+ // window refusal's `quickchr clean` route leads to a second refusal (#182).
2214
+ if (hasProvisioningRequest(request)) {
2215
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation({
2216
+ ...request,
2217
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)),
2218
+ }));
2219
+ }
2220
+ const satisfied = assertProvisioningWindow(existing, request);
2221
+ for (const ask of satisfied) {
2222
+ (logger ?? createLogger()).status(`${ask.description} is already applied — nothing to do.`);
2223
+ }
1699
2224
  if (isMachineRunning(existing)) {
1700
2225
  return createInstance(existing);
1701
2226
  }
1702
2227
  // 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,
2228
+ if (isProvisioningWindowOpen(existing)) {
2229
+ const pendingOpts = pendingProvisioningFor(existing, {
2230
+ installAllPackages: opts.installAllPackages,
2231
+ packages: opts.packages,
2232
+ deviceMode: opts.deviceMode,
2233
+ user: opts.user,
2234
+ disableAdmin: opts.disableAdmin,
1710
2235
  license: opts.license,
1711
- secureLogin: opts.secureLogin ?? existing.secureLogin,
1712
- };
2236
+ secureLogin: opts.secureLogin,
2237
+ });
1713
2238
  const hasPending = !!(
1714
2239
  pendingOpts.installAllPackages ||
1715
2240
  (pendingOpts.packages?.length ?? 0) > 0 ||
@@ -1720,49 +2245,43 @@ export class QuickCHR {
1720
2245
  pendingOpts.secureLogin === true
1721
2246
  );
1722
2247
  if (hasPending) {
2248
+ // Before the boot, not after it: `_provisionInstance()` re-checks the
2249
+ // version, but only once the guest is up — on RouterOS 6 that is a
2250
+ // download-free boot spent on a refusal known in advance.
2251
+ const pendingMutations = {
2252
+ ...pendingOpts,
2253
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(pendingOpts.deviceMode)),
2254
+ };
2255
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
2256
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
2257
+ requireHostFor(existing.arch);
1723
2258
  return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
1724
2259
  }
1725
2260
  }
1726
2261
  // Exists but stopped — restart it
1727
- return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger);
2262
+ requireHostFor(existing.arch);
2263
+ return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
1728
2264
  }
1729
2265
 
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
- );
2266
+ assertRouterOs6Arch(version, arch);
2267
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
2268
+ if (!opts.dryRun) requireHostFor(arch);
1753
2269
 
1754
- // Dry run — return instance handle without actually spawning
2270
+ // Dry run — return instance handle without actually spawning. Nothing is
2271
+ // claimed: a dry run persists nothing for another create to collide with.
1755
2272
  if (opts.dryRun) {
1756
2273
  const machineDir = getMachineDir(name);
2274
+ const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
1757
2275
  const state: MachineState = {
1758
2276
  name,
1759
2277
  version,
1760
2278
  arch,
1761
2279
  cpu: opts.cpu ?? 1,
1762
2280
  mem: defaultMem(arch, opts.mem),
1763
- networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
2281
+ networks,
1764
2282
  ports,
1765
2283
  packages: opts.packages ?? [],
2284
+ installAllPackages: opts.installAllPackages,
1766
2285
  deviceMode: requestedDeviceMode,
1767
2286
  user: opts.user,
1768
2287
  disableAdmin: opts.disableAdmin,
@@ -1780,13 +2299,40 @@ export class QuickCHR {
1780
2299
  return createInstance(state);
1781
2300
  }
1782
2301
 
1783
- // Acquire a lock to prevent concurrent starts of the same machine
1784
2302
  const machineDir = getMachineDir(name);
1785
- assertSufficientQuickchrStorage(`start CHR ${name}`);
2303
+ // Held until machine.json is saved — after the spawn, or in the foreground once
2304
+ // QEMU exits — and released on failure: see add().
2305
+ const storageLease = admitStorage(
2306
+ `start CHR ${name}`,
2307
+ estimateMachineCreation({ version, arch, packages: !!(opts.installAllPackages || opts.packages?.length) }),
2308
+ { logger: logger ? (message: string) => logger.status(message) : undefined },
2309
+ );
1786
2310
  const dirCreatedHere = !existsSync(machineDir);
1787
- ensureDir(machineDir);
1788
- const lockPath = join(machineDir, ".start-lock");
1789
- acquireLock(lockPath);
2311
+
2312
+ const background = opts.background ?? true;
2313
+
2314
+ // Provisioning includes any work that requires the machine to have booted first.
2315
+ // When foreground mode is requested WITH provisioning, we must boot in background,
2316
+ // provision (packages/user/license), then attach the serial socket to stdio.
2317
+ // Without provisioning, foreground mode runs QEMU with stdio directly (classic path).
2318
+ const hasProvisioning = !!(
2319
+ opts.installAllPackages ||
2320
+ (opts.packages && opts.packages.length > 0) ||
2321
+ opts.user ||
2322
+ opts.disableAdmin ||
2323
+ opts.license ||
2324
+ hasDeviceModeProvisioning ||
2325
+ opts.secureLogin === true
2326
+ );
2327
+
2328
+ let claimed: Awaited<ReturnType<typeof claimNewMachine>>;
2329
+ try {
2330
+ claimed = await claimNewMachine(name, !background && !hasProvisioning ? "foreground" : "start", opts);
2331
+ } catch (err) {
2332
+ storageLease.release();
2333
+ throw err;
2334
+ }
2335
+ const { lock, portBase, ports, networks: networkConfigs } = claimed;
1790
2336
  // Endpoints are claimed before QEMU is spawned, because resolution needs to know
1791
2337
  // which end this machine holds. A launch that fails before its state is persisted
1792
2338
  // has no instance lifecycle to release them later, so a failed start would leave a
@@ -1797,36 +2343,18 @@ export class QuickCHR {
1797
2343
 
1798
2344
  // Download and prepare image
1799
2345
  const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
1800
- copyImageToMachine(cachedImg, machineDir);
1801
2346
 
1802
- // Prepare disks (boot resize + extra disks)
2347
+ // Build the boot disk from the cache (resized if asked) and any extra disks
1803
2348
  const diskArtifacts = await ensureConfiguredDisks(
1804
2349
  machineDir,
1805
2350
  diskOpts.bootSize,
1806
2351
  diskOpts.extraDisks,
1807
2352
  diskOpts.bootDiskFormat,
2353
+ { booted: false, factoryImage: async () => cachedImg },
1808
2354
  );
1809
2355
  const bootDisk = diskArtifacts.bootDisk;
1810
2356
  const extraDiskConfigs = diskArtifacts.extraDisks;
1811
2357
 
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
2358
  if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1831
2359
  throw new QuickCHRError(
1832
2360
  "NETWORK_UNAVAILABLE",
@@ -1845,6 +2373,7 @@ export class QuickCHR {
1845
2373
  networks: networkConfigs,
1846
2374
  ports,
1847
2375
  packages: opts.packages ?? [],
2376
+ installAllPackages: opts.installAllPackages,
1848
2377
  deviceMode: requestedDeviceMode,
1849
2378
  user: opts.user,
1850
2379
  disableAdmin: opts.disableAdmin,
@@ -1912,8 +2441,10 @@ export class QuickCHR {
1912
2441
 
1913
2442
  saveMachine(state);
1914
2443
  unpersistedSocketClaim = undefined;
2444
+ storageLease.release();
1915
2445
 
1916
2446
  const instance = createInstance(state);
2447
+ if (opts.wait === false) return instance;
1917
2448
 
1918
2449
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1919
2450
 
@@ -1963,7 +2494,7 @@ export class QuickCHR {
1963
2494
  "BOOT_TIMEOUT",
1964
2495
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1965
2496
  (hasProvisioning ? " — provisioning could not run." : ".") +
1966
- await cleanupAfterBootFailure(instance, name, failure) +
2497
+ await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
1967
2498
  `\n${failure.summary}`,
1968
2499
  );
1969
2500
  }
@@ -1987,15 +2518,20 @@ export class QuickCHR {
1987
2518
  } catch { /* never propagate */ }
1988
2519
 
1989
2520
  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);
2521
+ try {
2522
+ await QuickCHR._provisionInstance(instance, state, {
2523
+ installAllPackages: opts.installAllPackages,
2524
+ packages: opts.packages,
2525
+ deviceMode: requestedDeviceMode,
2526
+ user: opts.user,
2527
+ disableAdmin: opts.disableAdmin,
2528
+ license: opts.license,
2529
+ secureLogin: opts.secureLogin,
2530
+ }, launchConfig, logger);
2531
+ } catch (err) {
2532
+ // This call created the machine, so a failed run takes it away again.
2533
+ throw await failProvisioning(err, instance, undefined);
2534
+ }
1999
2535
  }
2000
2536
 
2001
2537
  // Foreground + provisioning: all provisioning is done in background mode.
@@ -2003,12 +2539,12 @@ export class QuickCHR {
2003
2539
  // QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
2004
2540
  // shortcuts that are well-documented and googleable).
2005
2541
  if (!background && hasProvisioning) {
2006
- await instance.stop();
2542
+ await stopUnderLock(state);
2007
2543
  // Brief pause for QEMU to flush and release the disk image
2008
2544
  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 */ }
2545
+ // Release the lock before re-launching — _launchExisting acquires its own.
2546
+ // The outer finally releases again, which is a no-op.
2547
+ lock.release();
2012
2548
  return QuickCHR._launchExisting(state, false, undefined, logger);
2013
2549
  }
2014
2550
 
@@ -2026,11 +2562,12 @@ export class QuickCHR {
2026
2562
  // that already exist, and a directory this call did not create is never ours
2027
2563
  // to delete, whatever state it is in.
2028
2564
  if (dirCreatedHere && isOrphanMachineDir(name)) {
2029
- try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
2565
+ try { removeState(name); } catch { /* best effort */ }
2030
2566
  }
2031
2567
  throw err;
2032
2568
  } finally {
2033
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2569
+ lock.release();
2570
+ storageLease.release();
2034
2571
  }
2035
2572
  }
2036
2573
 
@@ -2052,6 +2589,9 @@ export class QuickCHR {
2052
2589
  logger?: ProgressLogger,
2053
2590
  ): Promise<void> {
2054
2591
  const log = logger ?? createLogger();
2592
+ // What actually ran, persisted at the end as MachineState.provisioning — the
2593
+ // record that says this guest has been provisioned, and with what (#176).
2594
+ const applied: ProvisioningStep[] = [];
2055
2595
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
2056
2596
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
2057
2597
  assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
@@ -2082,6 +2622,7 @@ export class QuickCHR {
2082
2622
  saveMachine(current);
2083
2623
  }
2084
2624
  machineState.packages = installed;
2625
+ applied.push("packages");
2085
2626
  } else if (opts.packages && opts.packages.length > 0) {
2086
2627
  const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
2087
2628
  await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
@@ -2091,10 +2632,14 @@ export class QuickCHR {
2091
2632
  saveMachine(current);
2092
2633
  }
2093
2634
  machineState.packages = installed;
2635
+ applied.push("packages");
2094
2636
  }
2095
2637
 
2096
2638
  if (hasDeviceModeProvisioning) {
2097
- await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log);
2639
+ // Factory admin on purpose: this runs at step 2 and the user step is step 4,
2640
+ // so `machineState.user` names an account that does not exist yet.
2641
+ await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log, FACTORY_AUTH_HEADER);
2642
+ applied.push("deviceMode");
2098
2643
  }
2099
2644
 
2100
2645
  if (opts.license) {
@@ -2123,6 +2668,7 @@ export class QuickCHR {
2123
2668
  saveMachine(current);
2124
2669
  }
2125
2670
  machineState.licenseLevel = actualLevel as LicenseLevel;
2671
+ applied.push("license");
2126
2672
  log.status(` License applied: free → ${actualLevel}`);
2127
2673
  } catch (e) {
2128
2674
  if (e instanceof QuickCHRError) throw e;
@@ -2134,6 +2680,9 @@ export class QuickCHR {
2134
2680
  }
2135
2681
 
2136
2682
  if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
2683
+ if (opts.user) applied.push("user");
2684
+ if (opts.disableAdmin) applied.push("disableAdmin");
2685
+ if (opts.secureLogin === true) applied.push("secureLogin");
2137
2686
  const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
2138
2687
  if (result.user || result.managedSshKey) {
2139
2688
  // Persist user info + managed SSH key fact in state (password placeholder —
@@ -2161,6 +2710,11 @@ export class QuickCHR {
2161
2710
  }
2162
2711
  }
2163
2712
  }
2713
+
2714
+ // Stamped only here, after every requested step returned. A run that throws
2715
+ // leaves the record absent, and the caller rolls the whole run back
2716
+ // (`rollbackFailedProvisioning()`), so the retry starts from a factory disk.
2717
+ recordProvisioning(machineState, applied);
2164
2718
  }
2165
2719
 
2166
2720
  /** Re-launch an existing stopped machine. */
@@ -2177,41 +2731,73 @@ export class QuickCHR {
2177
2731
  secureLogin?: boolean;
2178
2732
  },
2179
2733
  logger?: ProgressLogger,
2734
+ wait = true,
2180
2735
  ): Promise<ChrInstance> {
2181
- const lockPath = join(state.machineDir, ".start-lock");
2182
- acquireLock(lockPath);
2736
+ const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
2737
+ opts && (
2738
+ opts.installAllPackages ||
2739
+ (opts.packages?.length ?? 0) > 0 ||
2740
+ opts.deviceMode ||
2741
+ opts.user ||
2742
+ opts.disableAdmin ||
2743
+ opts.license ||
2744
+ opts.secureLogin === true
2745
+ )
2746
+ );
2747
+ let hasProvisioning = wantsProvisioning(provisioningOpts);
2748
+ const lock = acquireLockNow(
2749
+ machineLockPath(state.machineDir),
2750
+ !background && !hasProvisioning ? "foreground" : "start",
2751
+ machineLockOptions(state.name, state.machineDir),
2752
+ );
2183
2753
  // Same pre-persistence window as start(): a relaunch that claims an endpoint and
2184
2754
  // then fails to spawn would keep it, with no instance to release it.
2185
2755
  let unpersistedSocketClaim: MachineState | undefined;
2186
2756
  try {
2187
2757
 
2188
- const diskPath = join(state.machineDir, "disk.img");
2189
- if (!existsSync(diskPath)) {
2190
- throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
2758
+ // `state` is the caller's snapshot, read before the lock was taken; everything
2759
+ // below acts on it, `state.pid` included (#180). Re-read it, and if another
2760
+ // start got the machine running in between, hand back that one rather than
2761
+ // booting a second QEMU on the same disk.
2762
+ assertSameMachine(state);
2763
+ refreshMachineState(state);
2764
+ if (isMachineRunning(state)) return createInstance(state);
2765
+ // `provisioningOpts` was worked out from that snapshot too. If another start
2766
+ // booted the machine in between, the window is shut and running them would be
2767
+ // a second first boot: check them against the machine as it is now, and drop
2768
+ // whatever that start already applied.
2769
+ if (provisioningOpts && !isProvisioningWindowOpen(state)) {
2770
+ assertProvisioningWindow(state, provisioningOpts);
2771
+ provisioningOpts = undefined;
2772
+ hasProvisioning = false;
2191
2773
  }
2192
2774
 
2775
+ // Checks the disk this machine boots from — a qcow2 machine needs only boot.qcow2
2776
+ // (#200) — and refuses, rather than rebuilds, a booted machine's missing disk.
2193
2777
  const diskArtifacts = await ensureConfiguredDisks(
2194
2778
  state.machineDir,
2195
2779
  state.bootSize,
2196
2780
  state.extraDisks,
2197
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
2781
+ bootDiskFormatOf(state),
2782
+ {
2783
+ booted: !isProvisioningWindowOpen(state),
2784
+ factoryImage: () => ensureCachedImage(state.version, state.arch, undefined, logger),
2785
+ },
2198
2786
  );
2199
2787
  const bootDisk = diskArtifacts.bootDisk;
2200
2788
  const extraDiskConfigs = diskArtifacts.extraDisks;
2201
2789
 
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
2790
  // Always boot in background when provisioning is needed
2214
2791
  const spawnBackground = hasProvisioning ? true : background;
2792
+ // The record a failed first boot rolls back to — taken before this launch writes
2793
+ // pid, status or lastStartedAt into `state`. Whenever the window is open, not only
2794
+ // when there is provisioning to run: a boot timeout on an add()-created machine is
2795
+ // the same failed first boot as a provisioning error, and resets the same way
2796
+ // instead of deleting what `add` recorded (#182).
2797
+ const beforeLaunch = isProvisioningWindowOpen(state) ? structuredClone(state) : undefined;
2798
+ const bootFailureCleanup: BootFailureCleanup = beforeLaunch
2799
+ ? { kind: "reset-first-boot", restore: beforeLaunch }
2800
+ : { kind: "preserve-existing" };
2215
2801
 
2216
2802
  const platform = await detectPlatform();
2217
2803
  const accel = await detectAccel(state.arch);
@@ -2263,6 +2849,7 @@ export class QuickCHR {
2263
2849
  unpersistedSocketClaim = undefined;
2264
2850
 
2265
2851
  const instance = createInstance(state);
2852
+ if (!wait) return instance;
2266
2853
 
2267
2854
  // Always wait for boot in background mode — start() promises "REST-ready".
2268
2855
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
@@ -2292,7 +2879,7 @@ export class QuickCHR {
2292
2879
  "BOOT_TIMEOUT",
2293
2880
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
2294
2881
  (hasProvisioning ? " — provisioning could not run." : ".") +
2295
- await cleanupAfterBootFailure(instance, state.name, failure) +
2882
+ await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
2296
2883
  `\n${failure.summary}`,
2297
2884
  );
2298
2885
  }
@@ -2314,7 +2901,11 @@ export class QuickCHR {
2314
2901
  } catch { /* never propagate */ }
2315
2902
 
2316
2903
  if (hasProvisioning && provisioningOpts) {
2317
- await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2904
+ try {
2905
+ await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2906
+ } catch (err) {
2907
+ throw await failProvisioning(err, instance, beforeLaunch);
2908
+ }
2318
2909
  }
2319
2910
 
2320
2911
  try {
@@ -2330,7 +2921,7 @@ export class QuickCHR {
2330
2921
  if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2331
2922
  throw err;
2332
2923
  } finally {
2333
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2924
+ lock.release();
2334
2925
  }
2335
2926
  }
2336
2927
 
@@ -2386,20 +2977,20 @@ export class QuickCHR {
2386
2977
  // A create still in flight is indistinguishable from an orphan by directory
2387
2978
  // contents alone — add()/start() write machine.json last, so the window between
2388
2979
  // 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;
2980
+ // machine lock is what tells them apart, so take it: it throws MACHINE_LOCKED
2981
+ // while a live creator holds it, and takes over one whose owner is gone — which
2982
+ // is the case this recovery exists for. No waiting: a live creator means this is
2983
+ // not an orphan, and the answer is to let it finish.
2984
+ const lock = acquireLockNow(machineLockPath(getMachineDir(name)), "remove-orphan", machineLockOptions(name, getMachineDir(name)));
2985
+ try {
2986
+ // The creator may have finished between the check above and the lock.
2987
+ if (!isOrphanMachineDir(name)) return false;
2988
+ removeState(name);
2989
+ return true;
2990
+ } finally {
2991
+ // A no-op once the directory has gone; releases it if removal threw.
2992
+ lock.release();
2399
2993
  }
2400
-
2401
- removeState(name);
2402
- return true;
2403
2994
  }
2404
2995
 
2405
2996
  /** Run doctor checks for prerequisites. */
@@ -2506,14 +3097,14 @@ export class QuickCHR {
2506
3097
  detail: `${machines.length} instance${machines.length !== 1 ? "s" : ""} (${running} running, ${formatDiskSize(storage.machinesBytes)} on disk)`,
2507
3098
  });
2508
3099
 
2509
- let storageDetail = `${storage.path}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
3100
+ let storageDetail = `${storage.statPath}: ${formatDiskSize(storage.freeBytes)} free; ${formatQuickchrUsage(storage)}`;
2510
3101
  if (storage.status === "error") {
2511
- storageDetail += ` — below required minimum ${formatDiskSize(storage.recommendedFreeBytes)}`;
3102
+ storageDetail += ` — below the ${formatDiskSize(storage.reserveBytes)} reserve: new machines and image downloads are refused (after evicting what the cache can spare)`;
2512
3103
  } else if (storage.status === "warn") {
2513
3104
  storageDetail += ` — low headroom (target ${formatDiskSize(storage.warningFreeBytes)} free)`;
2514
3105
  }
2515
3106
  checks.push({
2516
- label: storage.label === ".local" ? "Storage (.local)" : "Storage",
3107
+ label: "Storage",
2517
3108
  status: storage.status,
2518
3109
  detail: storageDetail,
2519
3110
  });