@tikoci/quickchr 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,15 +16,19 @@ import type {
16
16
  LicenseInput,
17
17
  LicenseLevel,
18
18
  MachineState,
19
+ NetworkConfig,
19
20
  NetworkTopologyEntry,
21
+ PlatformInfo,
20
22
  PortMapping,
23
+ ProvisioningStep,
21
24
  QgaCommand,
22
25
  ServiceEndpoint,
23
26
  SnapshotInfo,
24
27
  SshServiceEndpoint,
25
28
  StartOptions,
26
29
  } from "./types.ts";
27
- import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.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";
28
32
  import packageJson from "../../package.json";
29
33
  import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
30
34
  import {
@@ -32,18 +36,25 @@ import {
32
36
  isValidVersion,
33
37
  generateMachineName,
34
38
  assertProvisioningSupportedVersion,
39
+ assertRestAvailable,
40
+ assertRouterOs6Arch,
41
+ isRouterOs6,
35
42
  PROVISIONING_FEATURE_LABEL,
36
43
  } from "./versions.ts";
37
- import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
44
+ import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
38
45
  import {
39
46
  getUsedPortBases,
47
+ getUsedMacs,
40
48
  saveMachine,
41
49
  loadMachine,
42
50
  loadAllMachines,
51
+ listUnreadableMachines,
43
52
  removeMachine as removeState,
44
53
  getMachineDir,
45
54
  getMachinesDir,
46
55
  listMachineNames,
56
+ isOrphanMachineDir,
57
+ listOrphanMachineDirs,
47
58
  refreshAllStatuses,
48
59
  isMachineRunning,
49
60
  ensureDir,
@@ -54,23 +65,26 @@ import {
54
65
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
55
66
  import { autoPruneIfOverCap } from "./cache.ts";
56
67
  import { resolveSetting } from "./settings.ts";
57
- import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
68
+ import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, waitForApiReady, readinessProbeFor, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
58
69
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
59
70
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
60
- 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";
61
72
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
62
73
  import { provision } from "./provision.ts";
63
74
  import { renewLicense, getLicenseInfo } from "./license.ts";
64
75
  import { resolveAuth, resolveCreds } from "./auth.ts";
65
76
  import { scpPush, scpPull } from "./scp.ts";
66
77
  import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
78
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownGuest, validateShutdownTimeout } from "./shutdown.ts";
67
79
  import { restExecute } from "./exec.ts";
68
80
  import { qgaExec } from "./qga.ts";
69
81
  import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
70
82
  import { restRequest, restGet, restPost } from "./rest.ts";
71
- import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
83
+ import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
72
84
  import { createLogger, type ProgressLogger } from "./log.ts";
73
85
  import {
86
+ describeDeviceModeChange,
87
+ FACTORY_AUTH_HEADER,
74
88
  formatDeviceModeSelection,
75
89
  readDeviceMode,
76
90
  resolveDeviceModeOptions,
@@ -79,11 +93,19 @@ import {
79
93
  verifyDeviceMode,
80
94
  waitForDeviceModeApi,
81
95
  } from "./device-mode.ts";
96
+ import {
97
+ appliedDeviceModeRecord,
98
+ assertProvisioningWindow,
99
+ hasProvisioningRequest,
100
+ isProvisioningWindowOpen,
101
+ pendingProvisioningFor,
102
+ } from "./provisioning-window.ts";
82
103
  import type { LicenseOptions } from "./types.ts";
83
104
  import type { GuestExec } from "./guest-snapshot.ts";
84
105
  import { toChrPorts } from "./network.ts";
85
106
  import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
86
- import { existsSync, readdirSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
107
+ import { existsSync, rmSync, copyFileSync } from "node:fs";
108
+ import { acquireLock, acquireLockNow, allocationLockPath, legacyMachineLockPath, listReservations, machineLockPath, type HeldLock, type LockOperation } from "./lock.ts";
87
109
  import { join } from "node:path";
88
110
 
89
111
  // --- Architecture-aware defaults ---
@@ -133,6 +155,16 @@ function normalizeStartOptions(opts: StartOptions): StartOptions {
133
155
  return opts;
134
156
  }
135
157
 
158
+ /** `version` and `channel` both name the release, so a caller passing both has asked
159
+ * for two things and gets one: `version` wins. Say so rather than drop `channel` in
160
+ * silence — the TSDoc used to call them "mutually exclusive" while nothing enforced
161
+ * it (#178). A warning, not a throw, matching the channel-name-as-version case. */
162
+ function warnVersionWithChannel(opts: StartOptions, logger: ProgressLogger): void {
163
+ if (opts.version && opts.channel) {
164
+ logger.warn(`Both version "${opts.version}" and channel "${opts.channel}" were given — using version ${opts.version}; channel is ignored.`);
165
+ }
166
+ }
167
+
136
168
  function listProvisioningMutations(opts: {
137
169
  installAllPackages?: boolean;
138
170
  packages?: string[];
@@ -178,6 +210,22 @@ function describeProvisioningOperation(opts: Parameters<typeof listProvisioningM
178
210
  return `use ${PROVISIONING_FEATURE_LABEL} (${joinHumanList(operations)})`;
179
211
  }
180
212
 
213
+ /** `wait: false` returns before the guest has booted, and provisioning needs a booted
214
+ * guest — so the pair is refused by name rather than the provisioning silently
215
+ * skipped (#159). `machine` is set when the provisioning is first-boot intent that
216
+ * `add()` recorded, which the caller may not have typed on this command line. */
217
+ function assertWaitAllowsProvisioning(opts: StartOptions, operation: string, machine?: string): void {
218
+ if (opts.wait !== false) return;
219
+ const source = machine
220
+ ? `machine "${machine}" has first-boot provisioning recorded by 'add' (${operation})`
221
+ : `provisioning was requested (${operation})`;
222
+ throw new QuickCHRError(
223
+ "INVALID_ARGUMENT",
224
+ `--no-wait (wait: false) cannot be combined with provisioning: ${source}, and provisioning runs against the booted guest, so start has to wait for it. ` +
225
+ "Drop --no-wait for this first boot — later starts can use it.",
226
+ );
227
+ }
228
+
181
229
  // --- Socket registry lifecycle helpers ---
182
230
 
183
231
  function getSocketNamedNetworks(state: MachineState): string[] {
@@ -186,19 +234,97 @@ function getSocketNamedNetworks(state: MachineState): string[] {
186
234
  .map((n) => (n.specifier as { type: "socket"; name: string }).name);
187
235
  }
188
236
 
237
+ /** Refuse a start that a named socket has no room for, before any I/O.
238
+ *
239
+ * Only a *new* member is refused: a machine already holding an endpoint is simply
240
+ * restarting into its own slot. An unknown socket is left alone — `start()`
241
+ * auto-creates it further down. */
242
+ function assertNamedSocketsHaveRoom(opts: StartOptions, logger: ProgressLogger): void {
243
+ const named = resolveStartNetworks(opts.networks, opts.network)
244
+ .map((n) => n.specifier)
245
+ .filter((spec): spec is { type: "socket"; name: string } =>
246
+ typeof spec === "object" && spec !== null && spec.type === "socket");
247
+ for (const { name } of named) {
248
+ const entry = getNamedSocket(name);
249
+ if (!entry?.endpoints) continue;
250
+ if (opts.name && entry.endpoints.includes(opts.name)) continue;
251
+ if (entry.endpoints.includes(null)) continue;
252
+ const held = entry.endpoints.filter((m): m is string => m !== null);
253
+ logger.debug(`socket::${name} endpoints held by ${held.join(", ")}`);
254
+ throw new QuickCHRError(
255
+ "NETWORK_UNAVAILABLE",
256
+ `Named socket "${name}" is a ${entry.mode} link and carries 2 machines; ` +
257
+ `${held.join(" and ")} already hold both ends. ` +
258
+ `Stop one of them, or create an N-way segment with 'quickchr networks sockets create <name> --mode mcast'.`,
259
+ );
260
+ }
261
+ }
262
+
263
+ /** QEMU version for the binary this machine's arch will run on, when it can be read.
264
+ * `-netdev dgram` needs 7.2, and a named socket should say so rather than failing at
265
+ * spawn with a netdev QEMU does not recognize. */
266
+ function qemuVersionForArch(platform: PlatformInfo, arch: Arch): string | undefined {
267
+ const bin = arch === "arm64" ? platform.qemuBinArm64 : platform.qemuBinX86;
268
+ return bin ? getQemuVersion(bin) : undefined;
269
+ }
270
+
189
271
  function registerSocketMembers(state: MachineState): void {
190
272
  for (const name of getSocketNamedNetworks(state)) {
191
273
  try {
192
- if (!getNamedSocket(name)) {
193
- createNamedSocket(name, { autoCreated: true });
194
- }
195
- addSocketMember(name, state.name);
274
+ // Create-if-missing and join under one registry lock: two concurrent starts
275
+ // both pass a test-then-create, and both then claim the same endpoint slot.
276
+ joinNamedSocket(name, state.name, { autoCreated: true });
196
277
  } catch (e) {
278
+ // A full two-member link is a refusal, not a warning: carrying on would spawn
279
+ // QEMU with no endpoint to bind, and on a `dgram` link a second machine
280
+ // binding the same path unlinks the first one's socket and steals the link
281
+ // with nothing logged on either side.
282
+ if (e instanceof QuickCHRError && e.code === "NETWORK_UNAVAILABLE") throw e;
197
283
  console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
198
284
  }
199
285
  }
200
286
  }
201
287
 
288
+ /** Report the transport each named socket resolved to.
289
+ *
290
+ * #158's acceptance bar: nothing about a named socket should require opening a file
291
+ * under the data dir. The field report that produced these issues had to read
292
+ * `machine.json` to discover its link was UDP multicast, after ping had already
293
+ * failed silently. */
294
+ function reportSocketTransports(state: MachineState, logger: ProgressLogger): void {
295
+ for (const name of getSocketNamedNetworks(state)) {
296
+ const entry = getNamedSocket(name);
297
+ if (!entry) continue;
298
+ logger.status(` Network socket::${name}: ${describeSocketTransport(entry, getSocketSlot(entry, state.name))}`);
299
+ }
300
+ }
301
+
302
+ /** Claim each named socket's endpoint, then resolve — releasing the claims if
303
+ * resolution fails.
304
+ *
305
+ * Membership is persisted *before* networks resolve, because the resolver needs to
306
+ * know which end this machine holds. So a resolution that throws — a `dgram` link on
307
+ * Windows, or on a QEMU older than 7.2 — would otherwise leave the machine holding an
308
+ * endpoint it never used, and two failed starts would fill a link with machines that
309
+ * are not running. */
310
+ export function registerAndResolveNetworks(
311
+ state: MachineState,
312
+ ctx: { platform: PlatformInfo; qemuVersion?: string },
313
+ hostfwd: string,
314
+ ): NetworkConfig[] {
315
+ try {
316
+ // Inside the try, not before it: this claims one endpoint per named socket, so a
317
+ // machine on two links whose second link is full would otherwise keep the first
318
+ // claim — and the caller cannot clean it either, since it only learns about the
319
+ // claim once this returns.
320
+ registerSocketMembers(state);
321
+ return resolveAllNetworks(state.networks, { ...ctx, machine: state.name }, hostfwd);
322
+ } catch (e) {
323
+ unregisterSocketMembers(state);
324
+ throw e;
325
+ }
326
+ }
327
+
202
328
  function unregisterSocketMembers(state: MachineState): void {
203
329
  for (const name of getSocketNamedNetworks(state)) {
204
330
  try {
@@ -231,6 +357,193 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
231
357
  return null;
232
358
  }
233
359
 
360
+ /** Put a machine's disks back to the factory image and drop the credential material
361
+ * that authenticated to the erased guest. Machine state is the caller's to update:
362
+ * `clean()` clears the guest facts and keeps intent, a provisioning rollback restores
363
+ * the record it had before the failed run. QEMU must already be stopped. */
364
+ async function resetGuestToFactory(state: MachineState): Promise<void> {
365
+ // NOTE: this deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
366
+ // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
367
+ // disk), not OS state; wiping it forces a full device scan (~480s) on the next
368
+ // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
369
+ // anchor: the "clean() resets disk to factory defaults" arm64 integration test
370
+ // (120s reboot wait would blow past 480s if the wipe were reintroduced).
371
+ // Re-copy fresh image from cache
372
+ const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
373
+ if (!existsSync(imgPath)) {
374
+ throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
375
+ }
376
+ const destPath = join(state.machineDir, "disk.img");
377
+ copyFileSync(imgPath, destPath);
378
+
379
+ // Clean up disk files (boot.qcow2, extra disks)
380
+ cleanDiskFiles(state.machineDir);
381
+
382
+ // Re-prepare disks if the machine had disk customizations
383
+ await ensureConfiguredDisks(
384
+ state.machineDir,
385
+ state.bootSize,
386
+ state.extraDisks,
387
+ state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
388
+ );
389
+
390
+ deleteInstanceCredentials(state.name);
391
+ // The managed keypair authenticated to the account factory reset erased,
392
+ // so it is dead credential material sitting in the machine dir. Removing
393
+ // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
394
+ // existing file exits 1 with "already exists" rather than overwriting
395
+ // (verified locally), so a leftover key would break `installSshKey()`.
396
+ rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
397
+ }
398
+
399
+ /** Options every machine-lock acquisition passes: the pre-0.5 lock to honour, and the
400
+ * noun for `MACHINE_LOCKED`. */
401
+ function machineLockOptions(name: string, machineDir: string): { legacyPath: string; subject: string } {
402
+ return { legacyPath: legacyMachineLockPath(machineDir), subject: `Machine "${name}"` };
403
+ }
404
+
405
+ /** The machine lock outlives its machine (see lock.ts), so a caller that has just
406
+ * taken it must check that the machine it was asked about is still there, and is not
407
+ * another machine created under the same name while it waited. Acting on the old
408
+ * snapshot would mean stopping a stale pid. */
409
+ function assertSameMachine(state: MachineState): void {
410
+ const fresh = loadMachine(state.name);
411
+ if (!fresh || fresh.createdAt !== state.createdAt) {
412
+ throw new QuickCHRError(
413
+ "MACHINE_NOT_FOUND",
414
+ `Machine "${state.name}" was removed${fresh ? " and created again" : ""} while this operation waited for it.`,
415
+ );
416
+ }
417
+ }
418
+
419
+ /** How long `stop`/`clean`/`remove` wait for an operation already holding the machine
420
+ * lock. The holder is almost always a start or relaunch waiting for boot, so the wait
421
+ * is that boot budget: a `stop` issued during a `start` runs after it rather than
422
+ * failing, and a holder that never finishes still cannot block it forever (#181). */
423
+ function lifecycleLockWaitMs(state: MachineState): number {
424
+ const withPackages = !!state.installAllPackages || (state.packages?.length ?? 0) > 0;
425
+ return defaultBootTimeout(state.arch, withPackages, state.lastAccel);
426
+ }
427
+
428
+ /** Run a lifecycle operation under the machine's lock, waiting for a current holder.
429
+ * State is re-read once the lock is held: a handle's snapshot predates whatever the
430
+ * holder just did, and `state.pid` read from it may name a process that has exited or
431
+ * one the OS has since reused (#180, #181). */
432
+ async function withMachineLock<T>(state: MachineState, op: LockOperation, fn: () => Promise<T>): Promise<T> {
433
+ const lock = await acquireLock(machineLockPath(state.machineDir), op, {
434
+ ...machineLockOptions(state.name, state.machineDir),
435
+ waitMs: lifecycleLockWaitMs(state),
436
+ onWait: (holder) => createLogger().warn(
437
+ `Machine "${state.name}" is busy with \`${holder.op}\` (pid ${holder.pid}) — waiting for it to finish before \`${op}\`.`,
438
+ ),
439
+ });
440
+ try {
441
+ assertSameMachine(state);
442
+ refreshMachineState(state);
443
+ return await fn();
444
+ } finally {
445
+ lock.release();
446
+ }
447
+ }
448
+
449
+ /** The body of `stop()`, for a caller that already holds the machine lock. The lock is
450
+ * not reentrant, so code inside a locked start or relaunch calls this, never
451
+ * `instance.stop()`. */
452
+ async function stopUnderLock(state: MachineState): Promise<void> {
453
+ if (state.pid) {
454
+ await stopQemu(state.pid);
455
+ }
456
+ finalizeStoppedState(state);
457
+ }
458
+
459
+ /** Persist stopped state and release endpoints. Caller holds the lifecycle lock. */
460
+ function finalizeStoppedState(state: MachineState): void {
461
+ unregisterSocketMembers(state);
462
+ // Update persisted state
463
+ const current = loadMachine(state.name);
464
+ if (current) {
465
+ current.status = "stopped";
466
+ current.pid = undefined;
467
+ saveMachine(current);
468
+ }
469
+ state.status = "stopped";
470
+ state.pid = undefined;
471
+ }
472
+
473
+ /** The body of `remove()`, for a caller that already holds the machine lock. The lock
474
+ * file goes with the directory. */
475
+ async function removeUnderLock(state: MachineState): Promise<void> {
476
+ if (state.pid && isMachineRunning(state)) {
477
+ await stopQemu(state.pid);
478
+ }
479
+ unregisterSocketMembers(state);
480
+ // Clean up stored instance credentials
481
+ deleteInstanceCredentials(state.name);
482
+ removeState(state.name);
483
+ }
484
+
485
+ /** The body of `clean()`, for a caller that already holds the machine lock. */
486
+ async function cleanUnderLock(state: MachineState): Promise<void> {
487
+ if (state.pid && isMachineRunning(state)) {
488
+ await stopQemu(state.pid);
489
+ }
490
+ // Same invariant as stop()/remove(): a machine that is not running holds no
491
+ // endpoint. clean() stops QEMU directly rather than through stop(), so without
492
+ // this a cleaned machine keeps a pair link occupied and blocks its removal.
493
+ unregisterSocketMembers(state);
494
+ await resetGuestToFactory(state);
495
+
496
+ // Every guest-side credential went with the old disk, so the credential
497
+ // facts in machine.json now describe a machine that no longer exists.
498
+ // Left in place, `resolveAuth()` /
499
+ // `resolveCreds()` prefer `state.user` and authenticate every REST call,
500
+ // exec, and SCP as a deleted user — the confound #79 removes here.
501
+ // Clearing them restores the documented fallback: factory `admin` with an
502
+ // empty password, which is what a fresh CHR image actually answers to
503
+ // (anchored by the "clean() resets disk to factory defaults" integration
504
+ // test). `disableAdmin` goes with them because it is read as a live fact
505
+ // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
506
+ // image has admin enabled again. `licenseLevel` goes with them for the same
507
+ // reason: it is a read-back of the license the erased disk held, not an
508
+ // intent — quickchr never persists the license *input* — so leaving it
509
+ // behind would report a level the fresh image does not have. Provisioning
510
+ // *intent* — packages, deviceMode, secureLogin — is not guest state and
511
+ // survives, and is what the next `start()` re-applies now that the window
512
+ // reopens.
513
+ // (`resetGuestToFactory()` has already dropped the secret-store entry and the
514
+ // managed keypair, which describe the same erased account.)
515
+
516
+ // Update state. `provisioning` and `lastStartedAt` go with the credentials:
517
+ // both describe a guest that no longer exists, and together they are what
518
+ // closes the provisioning window. Leaving them behind is what stopped a cleaned
519
+ // machine from ever being provisioned again — the disk was factory-fresh and
520
+ // `start()` still refused to provision it (#176). `cleanedAt` keeps the
521
+ // forensic clue that the timestamp carried.
522
+ const cleanedAt = new Date().toISOString();
523
+ const current = loadMachine(state.name);
524
+ if (current) {
525
+ current.status = "stopped";
526
+ current.pid = undefined;
527
+ current.user = undefined;
528
+ current.managedSshKey = undefined;
529
+ current.disableAdmin = undefined;
530
+ current.provisioning = undefined;
531
+ current.lastStartedAt = undefined;
532
+ current.licenseLevel = undefined;
533
+ current.cleanedAt = cleanedAt;
534
+ saveMachine(current);
535
+ }
536
+ state.status = "stopped";
537
+ state.pid = undefined;
538
+ state.user = undefined;
539
+ state.managedSshKey = undefined;
540
+ state.disableAdmin = undefined;
541
+ state.provisioning = undefined;
542
+ state.lastStartedAt = undefined;
543
+ state.licenseLevel = undefined;
544
+ state.cleanedAt = cleanedAt;
545
+ }
546
+
234
547
  /** Create a ChrInstance handle from persisted MachineState. */
235
548
  function createInstance(state: MachineState): ChrInstance {
236
549
  const ports = toChrPorts(state.ports);
@@ -319,14 +632,16 @@ function createInstance(state: MachineState): ChrInstance {
319
632
  // keeps the excludePorts fix: excluding "http" while keeping "https" now
320
633
  // resolves rest-api onto the surviving secure port instead of a port that
321
634
  // doesn't exist.
322
- const restApi = buildHttpService(
323
- state.ports.https,
324
- state.ports.http,
325
- "rest-api",
326
- { secure: "https", plain: "http" },
327
- { username: creds.user, password: creds.password, basic, header: auth.header },
328
- "/rest",
329
- );
635
+ const restApi: ServiceEndpoint = isRouterOs6(state.version)
636
+ ? { available: false, unavailableReason: `RouterOS ${state.version} has no REST API — use native-api` }
637
+ : buildHttpService(
638
+ state.ports.https,
639
+ state.ports.http,
640
+ "rest-api",
641
+ { secure: "https", plain: "http" },
642
+ { username: creds.user, password: creds.password, basic, header: auth.header },
643
+ "/rest",
644
+ );
330
645
  const nativeApi = buildHttpService(
331
646
  state.ports["api-ssl"],
332
647
  state.ports.api,
@@ -412,9 +727,12 @@ function createInstance(state: MachineState): ChrInstance {
412
727
  sshPort: ports.ssh,
413
728
  portBase: state.portBase,
414
729
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
415
- tzspGatewayIp: "10.0.2.2",
730
+ hostGatewayIp: HOST_GATEWAY_IP,
416
731
 
417
732
  async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
733
+ if (readinessProbeFor(state.version) === "api") {
734
+ return waitForApiReady(ports.api, timeoutMs ?? 120_000, resolveCreds(state), stats);
735
+ }
418
736
  // Use resolved credentials so waitForBoot can validate the response body
419
737
  // on authenticated machines (post-provisioning, post-install reboots).
420
738
  const auth = resolveAuth(state);
@@ -435,97 +753,32 @@ function createInstance(state: MachineState): ChrInstance {
435
753
  },
436
754
 
437
755
  async stop(): Promise<void> {
438
- if (state.pid) {
439
- await stopQemu(state.pid);
440
- }
441
- unregisterSocketMembers(state);
442
- // Update persisted state
443
- const current = loadMachine(state.name);
444
- if (current) {
445
- current.status = "stopped";
446
- current.pid = undefined;
447
- saveMachine(current);
448
- }
449
- state.status = "stopped";
450
- state.pid = undefined;
756
+ await withMachineLock(state, "stop", () => stopUnderLock(state));
757
+ },
758
+
759
+ async shutdown(options) {
760
+ const timeoutMs = validateShutdownTimeout(options?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
761
+ return withMachineLock(state, "shutdown", async () => {
762
+ if (!state.pid || !isMachineRunning(state)) {
763
+ finalizeStoppedState(state);
764
+ return { outcome: "already-stopped" };
765
+ }
766
+ const pid = state.pid;
767
+ const result = await shutdownGuest(timeoutMs, async (requestTimeoutMs) => {
768
+ // Rebuild after the locked refresh: a waiting handle may predate a relaunch.
769
+ return createInstance(state).exec("/system/shutdown", { via: "rest", timeout: requestTimeoutMs });
770
+ }, () => isMachineRunning(state), () => stopQemu(pid));
771
+ finalizeStoppedState(state);
772
+ return result;
773
+ });
451
774
  },
452
775
 
453
776
  async remove(): Promise<void> {
454
- if (state.pid && isMachineRunning(state)) {
455
- await stopQemu(state.pid);
456
- }
457
- unregisterSocketMembers(state);
458
- // Clean up stored instance credentials
459
- deleteInstanceCredentials(state.name);
460
- removeState(state.name);
777
+ await withMachineLock(state, "remove", () => removeUnderLock(state));
461
778
  },
462
779
 
463
780
  async clean(): Promise<void> {
464
- if (state.pid && isMachineRunning(state)) {
465
- await stopQemu(state.pid);
466
- }
467
- // NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
468
- // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
469
- // disk), not OS state; wiping it forces a full device scan (~480s) on the next
470
- // boot. The fresh disk re-copy below is a sufficient factory reset. Regression
471
- // anchor: the "clean() resets disk to factory defaults" arm64 integration test
472
- // (120s reboot wait would blow past 480s if the wipe were reintroduced).
473
- // Re-copy fresh image from cache
474
- const imgPath = join(getCacheDir(), `chr-${state.version}${state.arch === "arm64" ? "-arm64" : ""}.img`);
475
- if (!existsSync(imgPath)) {
476
- throw new QuickCHRError("MACHINE_NOT_FOUND", `Cached image not found: ${imgPath}`);
477
- }
478
- const destPath = join(state.machineDir, "disk.img");
479
- copyFileSync(imgPath, destPath);
480
-
481
- // Clean up disk files (boot.qcow2, extra disks)
482
- cleanDiskFiles(state.machineDir);
483
-
484
- // Re-prepare disks if the machine had disk customizations
485
- await ensureConfiguredDisks(
486
- state.machineDir,
487
- state.bootSize,
488
- state.extraDisks,
489
- state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
490
- );
491
-
492
- // Every guest-side credential went with the old disk, so the credential
493
- // facts in machine.json now describe a machine that no longer exists.
494
- // Nothing re-provisions them either: a post-clean() start() takes the
495
- // `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
496
- // erased account is never recreated. Left in place, `resolveAuth()` /
497
- // `resolveCreds()` prefer `state.user` and authenticate every REST call,
498
- // exec, and SCP as a deleted user — the confound #79 removes here.
499
- // Clearing them restores the documented fallback: factory `admin` with an
500
- // empty password, which is what a fresh CHR image actually answers to
501
- // (anchored by the "clean() resets disk to factory defaults" integration
502
- // test). `disableAdmin` goes with them because it is read as a live fact
503
- // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
504
- // image has admin enabled again. Provisioning *intent* — packages,
505
- // deviceMode, secureLogin — is not guest state and survives.
506
- deleteInstanceCredentials(state.name);
507
- // The managed keypair authenticated to the account factory reset erased,
508
- // so it is dead credential material sitting in the machine dir. Removing
509
- // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
510
- // existing file exits 1 with "already exists" rather than overwriting
511
- // (verified locally), so a leftover key would break `installSshKey()`.
512
- rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
513
-
514
- // Update state
515
- const current = loadMachine(state.name);
516
- if (current) {
517
- current.status = "stopped";
518
- current.pid = undefined;
519
- current.user = undefined;
520
- current.managedSshKey = undefined;
521
- current.disableAdmin = undefined;
522
- saveMachine(current);
523
- }
524
- state.status = "stopped";
525
- state.pid = undefined;
526
- state.user = undefined;
527
- state.managedSshKey = undefined;
528
- state.disableAdmin = undefined;
781
+ await withMachineLock(state, "clean", () => cleanUnderLock(state));
529
782
  },
530
783
 
531
784
  async monitor(command: string): Promise<string> {
@@ -541,6 +794,7 @@ function createInstance(state: MachineState): ChrInstance {
541
794
  },
542
795
 
543
796
  async rest(path: string, opts?: RequestInit): Promise<unknown> {
797
+ assertRestAvailable(state, "rest()");
544
798
  const url = `${restUrl}/rest${path.startsWith("/") ? path : "/" + path}`;
545
799
 
546
800
  // Retry on ECONNRESET — RouterOS can transiently reset connections in the
@@ -630,6 +884,9 @@ function createInstance(state: MachineState): ChrInstance {
630
884
  `exec transport "${via}" is not yet implemented`,
631
885
  );
632
886
  }
887
+ // Before the auto→console fallback below: that fallback exists for an
888
+ // unreachable REST endpoint, not for one that cannot exist.
889
+ assertRestAvailable(state, "exec");
633
890
  const auth = resolveAuth(state, opts?.user, opts?.password);
634
891
  if (via === "rest") {
635
892
  return restExecute(restUrl, auth, command, opts);
@@ -677,15 +934,62 @@ function createInstance(state: MachineState): ChrInstance {
677
934
  const log = logger ?? createLogger();
678
935
  const resolved = resolveDeviceModeOptions(options);
679
936
  for (const w of resolved.warnings) log.warn(`Device-mode: ${w}`);
680
- const launchConfig = await buildLaunchConfigFromState(state);
681
- await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log);
682
- // Persist updated device-mode in state
683
- const current = loadMachine(state.name);
684
- if (current) {
685
- current.deviceMode = options;
686
- saveMachine(current);
937
+ if (!shouldApplyDeviceMode(resolved)) {
938
+ throw new QuickCHRError(
939
+ "INVALID_ARGUMENT",
940
+ `Nothing to set: device-mode options for "${state.name}" resolved to no change ` +
941
+ "(mode=skip disables device-mode entirely; pass a mode or a feature to enable/disable).",
942
+ );
943
+ }
944
+ // The same machine lock every other relaunch takes. This one is a relaunch:
945
+ // it terminates QEMU, spawns a replacement and rewrites `machine.json`, so a
946
+ // concurrent `start` would otherwise spawn a second QEMU into the power-cycle
947
+ // window and both would persist over each other. Fail-fast like a second
948
+ // `start`: waiting would only power-cycle the machine twice.
949
+ const lock = acquireLockNow(machineLockPath(state.machineDir), "device-mode", machineLockOptions(state.name, state.machineDir));
950
+ try {
951
+ assertSameMachine(state);
952
+ // Re-read under the lock before anything reads `state`. This handle's
953
+ // snapshot is taken once, at `createInstance`, and a library consumer can
954
+ // hold it across another process's stop/start — after which `state.pid`
955
+ // names a process that has exited (a false MACHINE_STOPPED) or, worse, one
956
+ // the OS has since reused, which `hardRebootMachine()` would then
957
+ // terminate. `state.provisioning` and `state.deviceMode` go stale the same
958
+ // way, and both feed `appliedDeviceModeRecord()`.
959
+ refreshMachineState(state);
960
+ // Inside the lock: the applicability check has to hold for the operation,
961
+ // not merely at the moment it was made.
962
+ assertDeviceModeApplicable(state);
963
+ const launchConfig = await buildLaunchConfigFromState(state);
964
+ // Post-boot auth, not factory admin: a machine provisioned with
965
+ // `--disable-admin` answers 401 to `admin:`, and those are exactly the
966
+ // machines this route exists for.
967
+ await applyDeviceMode(this as ChrInstance, state, resolved, launchConfig, log, resolveAuth(state).header);
968
+
969
+ // Persist what is now true of the guest: the applied selection folded into
970
+ // what was already *applied*, not the request on its own. Device-mode
971
+ // updates are cumulative — only the settings a request names move — so
972
+ // overwriting would leave `machine.json` describing a machine that does
973
+ // not exist.
974
+ //
975
+ // `appliedDeviceModeRecord` gates the carry-forward on the step record, for
976
+ // the reason #176 keeps running into: desired config is not evidence that
977
+ // a step ran.
978
+ const deviceMode = appliedDeviceModeRecord(state, resolved);
979
+ const current = loadMachine(state.name);
980
+ if (current) {
981
+ current.deviceMode = deviceMode;
982
+ saveMachine(current);
983
+ }
984
+ state.deviceMode = deviceMode;
985
+ // This is quickchr applying a provisioning step, so it belongs in the
986
+ // record the window reads (#176). It does not reopen the window — it adds
987
+ // the one step that just ran, so a later `start` passing the same
988
+ // device-mode is recognised as already applied instead of refused.
989
+ recordProvisioningStep(state, "deviceMode");
990
+ } finally {
991
+ lock.release();
687
992
  }
688
- state.deviceMode = options;
689
993
  },
690
994
 
691
995
  async availablePackages(): Promise<string[]> {
@@ -772,8 +1076,10 @@ function createInstance(state: MachineState): ChrInstance {
772
1076
  },
773
1077
 
774
1078
  async destroy(): Promise<void> {
775
- await this.stop();
776
- await this.remove();
1079
+ await withMachineLock(state, "remove", async () => {
1080
+ await stopUnderLock(state);
1081
+ await removeUnderLock(state);
1082
+ });
777
1083
  },
778
1084
 
779
1085
  async subprocessEnv(): Promise<Record<string, string>> {
@@ -913,44 +1219,6 @@ function createInstance(state: MachineState): ChrInstance {
913
1219
  };
914
1220
  }
915
1221
 
916
- /**
917
- * Atomically acquire an exclusive start lock for a machine directory.
918
- * Uses O_CREAT|O_EXCL so concurrent attempts are rejected by the OS — no TOCTOU race.
919
- * If the lock is stale (owning process is dead) it is silently replaced.
920
- * Throws MACHINE_LOCKED if the lock is held by a live process.
921
- */
922
- export function acquireLock(lockPath: string): void {
923
- let fd: number;
924
- try {
925
- fd = openSync(lockPath, "wx"); // O_WRONLY | O_CREAT | O_EXCL — atomic
926
- writeSync(fd, String(process.pid));
927
- closeSync(fd);
928
- return;
929
- } catch {
930
- // File already exists — check whether owner is still alive
931
- }
932
-
933
- let ownerPid: number | undefined;
934
- try {
935
- ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
936
- } catch { /* unreadable — treat as stale */ }
937
-
938
- if (ownerPid && !Number.isNaN(ownerPid)) {
939
- try {
940
- process.kill(ownerPid, 0); // probes liveness; throws ESRCH if process not found
941
- throw new QuickCHRError("MACHINE_LOCKED", `Machine is already being started (pid ${ownerPid})`);
942
- } catch (e) {
943
- if (e instanceof QuickCHRError) throw e;
944
- // Process is dead — stale lock, overwrite
945
- writeFileSync(lockPath, String(process.pid));
946
- return;
947
- }
948
- }
949
-
950
- // Owner unreadable or zero — treat as stale, overwrite
951
- writeFileSync(lockPath, String(process.pid));
952
- }
953
-
954
1222
  /** Resolve the host architecture to a CHR architecture. */
955
1223
  function hostArchToChr(): Arch {
956
1224
  const arch = process.arch;
@@ -1130,28 +1398,122 @@ export async function captureRunningFailure(
1130
1398
  });
1131
1399
  }
1132
1400
 
1133
- /** Tear down a machine that failed to boot, and describe what was done.
1134
- *
1135
- * Default is full cleanup — a failed `start()` must not leave a half-dead
1136
- * machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
1137
- * inverts that for **local** debugging: the QEMU process is still stopped (it is
1138
- * wedged, and its port block must be released), but the machine directory
1139
- * survives so it can be inspected by hand.
1140
- *
1141
- * Evidence does not depend on that flag, and CI deliberately does not set it —
1142
- * `captureBootFailure()` has already written its report outside the machine dir
1143
- * with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
1144
- async function cleanupAfterBootFailure(
1401
+ /** Cleanup intent is captured under the lifecycle lock, before launch mutates state. */
1402
+ export type BootFailureCleanup =
1403
+ | { kind: "remove-new" }
1404
+ | { kind: "reset-first-boot"; restore: MachineState }
1405
+ | { kind: "preserve-existing" };
1406
+
1407
+ /** Stop the failed guest, then apply the launch's cleanup intent.
1408
+ * Diagnostics have already been captured outside the machine directory. */
1409
+ export async function cleanupAfterBootFailure(
1145
1410
  instance: ChrInstance,
1146
1411
  name: string,
1147
1412
  failure: BootFailureReport,
1413
+ cleanup: BootFailureCleanup,
1148
1414
  ): Promise<string> {
1149
- try { await instance.stop(); } catch { /* ignore */ }
1415
+ const quoted = shellQuote(name);
1416
+ // Called from inside a start or relaunch that holds the machine lock.
1417
+ try {
1418
+ await stopUnderLock(instance.state);
1419
+ } catch (err) {
1420
+ const reason = err instanceof Error ? err.message : String(err);
1421
+ return ` Machine "${name}" was retained, but stopping QEMU failed (${reason}) — retry with \`quickchr stop ${quoted}\` before starting or removing it.`;
1422
+ }
1423
+ if (cleanup.kind === "preserve-existing") {
1424
+ return ` Machine "${name}" was stopped and preserved with its disks and configuration — ` +
1425
+ `retry with \`quickchr start ${quoted}\`, or explicitly remove it with \`quickchr remove ${quoted}\`.`;
1426
+ }
1427
+ const restore = cleanup.kind === "reset-first-boot" ? cleanup.restore : undefined;
1150
1428
  if (failure.preserved) {
1151
- return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
1429
+ return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — ` +
1430
+ (restore ? `reset it with \`quickchr clean ${quoted}\` and start it again, or ` : "") +
1431
+ `remove it with \`quickchr remove ${quoted}\`.`;
1432
+ }
1433
+ // An add()-created machine on its first boot: the same reset a failed provisioning
1434
+ // run gets (#187), so the retry is `start` again rather than `add` + `start`. The
1435
+ // guest may have written to its disk before stalling, so the disk goes back to the
1436
+ // factory image along with the record — the window is open again, and an open
1437
+ // window promises a factory disk.
1438
+ if (restore) {
1439
+ try {
1440
+ await resetGuestToFactory(restore);
1441
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1442
+ refreshMachineState(instance.state);
1443
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — ` +
1444
+ `retry with \`quickchr start ${quoted}\`. Set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine as it is for diagnosis.`;
1445
+ } catch (resetErr) {
1446
+ const reason = resetErr instanceof Error ? resetErr.message : String(resetErr);
1447
+ return ` Resetting machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1448
+ }
1449
+ }
1450
+ try { await removeUnderLock(instance.state); } catch { /* ignore */ }
1451
+ return ` Machine "${name}" has been cleaned up automatically — set QUICKCHR_PRESERVE_ON_FAILURE=1 to keep a failed machine for diagnosis.`;
1452
+ }
1453
+
1454
+ /** Undo a first boot whose provisioning threw, so that retrying is re-running the same
1455
+ * command (#176).
1456
+ *
1457
+ * Provisioning is transactional — you get the machine you asked for or you don't —
1458
+ * and the throw used to break that: the guest was left running and half-provisioned,
1459
+ * with `lastStartedAt` set, so the window was closed and the retry a user reaches for
1460
+ * was refused. Resuming from the steps that landed is not the fix. A step can throw
1461
+ * half-applied (the login group creates a user and then disables `admin` in one call),
1462
+ * and quickchr cannot tell how far it got without reading the guest. So the rollback
1463
+ * reverts the whole run, and the retry starts again from a factory disk:
1464
+ *
1465
+ * - **`restore` given** — an `add()`-created machine on its first boot. The disk is
1466
+ * reset to the factory image and `machine.json` goes back to the record `add()`
1467
+ * wrote, window open and intent intact, so `quickchr start <name>` tries again.
1468
+ * - **no `restore`** — `start()` created the machine in this call, so it is removed,
1469
+ * exactly as a boot timeout removes it.
1470
+ *
1471
+ * `QUICKCHR_PRESERVE_ON_FAILURE=1` keeps the half-provisioned machine for inspection,
1472
+ * as it does for a boot failure. QEMU is stopped either way.
1473
+ *
1474
+ * Returns the sentence to append to the error. A rollback that fails is reported in
1475
+ * that sentence, never swallowed — the provisioning error stays the primary one. */
1476
+ export async function rollbackFailedProvisioning(
1477
+ instance: ChrInstance,
1478
+ restore: MachineState | undefined,
1479
+ ): Promise<string> {
1480
+ const name = instance.state.name;
1481
+ const quoted = shellQuote(name);
1482
+ // Called from inside a start or relaunch that holds the machine lock.
1483
+ try {
1484
+ await stopUnderLock(instance.state);
1485
+ if (preserveOnFailure()) {
1486
+ return ` Machine "${name}" was preserved half-provisioned for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — reset it with \`quickchr clean ${quoted}\` and repeat the start command, or remove it with \`quickchr remove ${quoted}\`.`;
1487
+ }
1488
+ if (!restore) {
1489
+ await removeUnderLock(instance.state);
1490
+ return ` Machine "${name}" has been removed — re-run the same command to retry.`;
1491
+ }
1492
+ await resetGuestToFactory(restore);
1493
+ saveMachine({ ...restore, status: "stopped", pid: undefined });
1494
+ refreshMachineState(instance.state);
1495
+ return ` Machine "${name}" was reset to its never-booted state, with the options \`add\` recorded — re-run the same start command to retry.`;
1496
+ } catch (rollbackErr) {
1497
+ const reason = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1498
+ return ` Rolling back machine "${name}" also failed (${reason}) — reset it with \`quickchr clean ${quoted}\` or remove it with \`quickchr remove ${quoted}\`.`;
1152
1499
  }
1153
- try { await instance.remove(); } catch { /* ignore */ }
1154
- return ` Machine "${name}" has been cleaned up automatically.`;
1500
+ }
1501
+
1502
+ /** Append the rollback outcome to a provisioning error and hand it back for rethrow.
1503
+ * The error keeps its own type, code and stack: the rollback is context, not the
1504
+ * failure. */
1505
+ export async function failProvisioning(
1506
+ err: unknown,
1507
+ instance: ChrInstance,
1508
+ restore: MachineState | undefined,
1509
+ ): Promise<unknown> {
1510
+ // Its own line: the provisioning error often ends in a RouterOS JSON body.
1511
+ const outcome = (await rollbackFailedProvisioning(instance, restore)).trim();
1512
+ if (err instanceof Error) {
1513
+ err.message += `\n${outcome}`;
1514
+ return err;
1515
+ }
1516
+ return new QuickCHRError("PROCESS_FAILED", `Provisioning failed: ${String(err)}\n${outcome}`);
1155
1517
  }
1156
1518
 
1157
1519
  /** Wait for CHR to boot with periodic progress status updates every 20s. */
@@ -1226,7 +1588,11 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1226
1588
  );
1227
1589
  const platform = await detectPlatform();
1228
1590
  const hostfwd = buildHostfwdString(state.ports);
1229
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
1591
+ const resolvedNetworks = resolveAllNetworks(
1592
+ state.networks,
1593
+ { platform, machine: state.name, qemuVersion: qemuVersionForArch(platform, state.arch) },
1594
+ hostfwd,
1595
+ );
1230
1596
  return {
1231
1597
  arch: state.arch,
1232
1598
  machineDir: state.machineDir,
@@ -1241,26 +1607,126 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1241
1607
  };
1242
1608
  }
1243
1609
 
1610
+ /** Persist what provisioning landed on the current disk, in state and on disk.
1611
+ * A run with nothing to do still records — an empty `steps` says "provisioning ran
1612
+ * and had nothing to apply", which is a different fact from never having run. */
1613
+ function recordProvisioning(state: MachineState, steps: ProvisioningStep[]): void {
1614
+ const record = { at: new Date().toISOString(), steps };
1615
+ const current = loadMachine(state.name);
1616
+ if (current) {
1617
+ current.provisioning = record;
1618
+ saveMachine(current);
1619
+ }
1620
+ state.provisioning = record;
1621
+ }
1622
+
1623
+ /** Re-read `machine.json` into an existing state object, in place.
1624
+ *
1625
+ * **In place** because `createInstance` closes over this object and exposes it as
1626
+ * `instance.state`: rebinding a local would leave the caller's view stale, and every
1627
+ * other method on the handle would keep reading the old snapshot.
1628
+ *
1629
+ * Keys the reloaded record does not have are deleted rather than left behind.
1630
+ * `clean()` removes `provisioning`, `user` and `disableAdmin` precisely because they
1631
+ * stopped being true, so a leftover would be read as fact by the next caller —
1632
+ * `appliedDeviceModeRecord()` reads exactly one of them. A plain `Object.assign`
1633
+ * cannot express a field that went away. */
1634
+ export function refreshMachineState(state: MachineState): void {
1635
+ const fresh = loadMachine(state.name);
1636
+ if (!fresh) return;
1637
+ const mutable = state as unknown as Record<string, unknown>;
1638
+ for (const key of Object.keys(mutable)) {
1639
+ if (!(key in fresh)) delete mutable[key];
1640
+ }
1641
+ Object.assign(state, fresh);
1642
+ }
1643
+
1644
+ /** Add one step to the provisioning record, keeping whatever is already there.
1645
+ *
1646
+ * `recordProvisioning()` stamps a whole first-boot run; this is for a step applied on
1647
+ * its own afterwards — `setDeviceMode()` today, `installPackage()` when #24 lands.
1648
+ * `steps` accumulates and `at` moves to now, which is what `ProvisioningRecord`
1649
+ * promises: the last time quickchr applied provisioning to this disk.
1650
+ *
1651
+ * It cannot reopen or close a window by accident. The only caller requires a running
1652
+ * machine, and a running machine has `lastStartedAt`, so the window was already shut
1653
+ * before this wrote anything. */
1654
+ function recordProvisioningStep(state: MachineState, step: ProvisioningStep): void {
1655
+ const steps = [...new Set([...(state.provisioning?.steps ?? []), step])];
1656
+ const record = { at: new Date().toISOString(), steps };
1657
+ const current = loadMachine(state.name);
1658
+ if (current) {
1659
+ current.provisioning = record;
1660
+ saveMachine(current);
1661
+ }
1662
+ state.provisioning = record;
1663
+ }
1664
+
1665
+ /** Preconditions a post-boot device-mode change inherits from provisioning.
1666
+ *
1667
+ * Both were undocumented and both fail confusingly when unchecked (#176):
1668
+ * `applyDeviceMode()` polls `waitForDeviceModeApi` before it does anything, so a
1669
+ * stopped machine spends 60 s to report a timeout, and a machine with no user-mode
1670
+ * NIC has no localhost route to REST at all and never recovers.
1671
+ *
1672
+ * This is the same guard `start()` applies to provisioning, moved to the one step
1673
+ * that may also run afterwards. */
1674
+ function assertDeviceModeApplicable(state: MachineState): void {
1675
+ if (!isMachineRunning(state)) {
1676
+ throw new QuickCHRError(
1677
+ "MACHINE_STOPPED",
1678
+ `Device-mode is applied through the guest's REST API, so "${state.name}" has to be running. ` +
1679
+ `Start it with 'quickchr start ${shellQuote(state.name)}', then set device-mode.`,
1680
+ );
1681
+ }
1682
+ if (!hasUserModeNetwork(state.networks)) {
1683
+ throw new QuickCHRError(
1684
+ "NETWORK_UNAVAILABLE",
1685
+ `Device-mode is applied over localhost REST, which needs a user-mode network interface — ` +
1686
+ `"${state.name}" has none, so quickchr cannot reach its REST API. ` +
1687
+ "Recreate the machine with a user-mode NIC alongside its other networks.",
1688
+ );
1689
+ }
1690
+ }
1691
+
1244
1692
  /** Apply a device-mode change to a running CHR instance (may require hard power-cycle).
1245
- * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic. */
1693
+ * Extracted from _provisionInstance so setDeviceMode() can reuse the same logic.
1694
+ *
1695
+ * `authHeader` is the caller's, because the two callers genuinely differ and the
1696
+ * difference is not cosmetic. During first-boot provisioning device-mode is step 2 and
1697
+ * the user step is step 4, so factory `admin:` is the *only* credential that exists
1698
+ * yet — `state.user` is already populated at that point but names an account nobody
1699
+ * has created, so resolving it here would 401 the shipped path. Post-boot the reverse
1700
+ * holds: a machine provisioned with `--disable-admin` answers 401 to factory admin,
1701
+ * which is what made `quickchr set <name> --device-mode` fail on exactly the machines
1702
+ * the route was written for. Same field, opposite answers, decided by the caller
1703
+ * rather than guessed here. */
1246
1704
  async function applyDeviceMode(
1247
1705
  instance: ChrInstance,
1248
1706
  machineState: MachineState,
1249
1707
  resolvedDeviceMode: ReturnType<typeof resolveDeviceModeOptions>,
1250
1708
  launchConfig: QemuLaunchConfig,
1251
1709
  log: ProgressLogger,
1710
+ authHeader: string,
1252
1711
  ): Promise<void> {
1253
1712
  const httpPort = toChrPorts(machineState.ports).http;
1254
1713
  const accel = await detectAccel(machineState.arch);
1255
1714
  const bootTimeout = defaultBootTimeout(machineState.arch, undefined, accel);
1256
- await waitForDeviceModeApi(httpPort, 60_000);
1715
+ await waitForDeviceModeApi(httpPort, 60_000, authHeader);
1257
1716
  log.status(`Applying device-mode (${formatDeviceModeSelection(resolvedDeviceMode)})...`);
1258
1717
 
1259
1718
  let alreadyActive = false;
1260
1719
  try {
1261
- const beforeMode = await readDeviceMode(httpPort);
1720
+ const beforeMode = await readDeviceMode(httpPort, authHeader);
1262
1721
  log.debug(`Device-mode before update: ${JSON.stringify(beforeMode)}`);
1263
1722
  alreadyActive = verifyDeviceMode(resolvedDeviceMode, beforeMode).ok;
1723
+ // Say what the power cycle is buying before spending it. `mode` moves as a
1724
+ // side-effect of enabling a feature — `--device-mode-enable container` resolves
1725
+ // `auto` to `rose` — and a change nobody asked for should at least be announced
1726
+ // (#176).
1727
+ for (const change of describeDeviceModeChange(resolvedDeviceMode, beforeMode)) {
1728
+ log.status(` ${change}`);
1729
+ }
1264
1730
  } catch { /* CHR unexpectedly unreachable — proceed with update */ }
1265
1731
 
1266
1732
  if (alreadyActive) {
@@ -1273,7 +1739,7 @@ async function applyDeviceMode(
1273
1739
  let requiresPowerCycle = false;
1274
1740
 
1275
1741
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1276
- const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode);
1742
+ const request = startDeviceModeUpdate(httpPort, resolvedDeviceMode, authHeader);
1277
1743
  // RouterOS blocks this connection while waiting for hard power-cycle confirmation.
1278
1744
  // We race against 2s: if still pending at 2s, RouterOS has entered blocking state
1279
1745
  // and we can confirm by killing QEMU. If it resolves in <2s ("returned early"),
@@ -1303,6 +1769,22 @@ async function applyDeviceMode(
1303
1769
  }
1304
1770
 
1305
1771
  log.debug(`Device-mode update response: HTTP ${outcome.response.status}`);
1772
+
1773
+ // A rejection is an answer, not a race — say what RouterOS said and stop.
1774
+ // Retrying here used to cost five POSTs and ~33 s and then report a *mismatch*
1775
+ // (`bogus-feature: expected=yes, actual=(missing)`) while RouterOS had replied
1776
+ // `HTTP 400 — unknown parameter bogus-feature` on the first attempt. RouterOS
1777
+ // also counts device-mode update attempts (`attempt-count` on
1778
+ // `/system/device-mode`), so the retries spend a budget that is not ours to
1779
+ // spend. Only a 2xx that came back before activation is worth trying again.
1780
+ const { status, body } = outcome.response;
1781
+ if (status < 200 || status >= 300) {
1782
+ throw new QuickCHRError(
1783
+ "PROCESS_FAILED",
1784
+ `Device-mode update rejected by RouterOS: HTTP ${status} — ${body.trim() || "(empty body)"}`,
1785
+ );
1786
+ }
1787
+
1306
1788
  await Bun.sleep(5000);
1307
1789
 
1308
1790
  let routerOsOffline = false;
@@ -1319,10 +1801,10 @@ async function applyDeviceMode(
1319
1801
  if (!rebooted) {
1320
1802
  throw new QuickCHRError("BOOT_TIMEOUT", "RouterOS device-mode internal reboot timed out");
1321
1803
  }
1322
- await waitForDeviceModeApi(httpPort, bootTimeout);
1804
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1323
1805
  }
1324
1806
 
1325
- const actualNow = await readDeviceMode(httpPort);
1807
+ const actualNow = await readDeviceMode(httpPort, authHeader);
1326
1808
  log.debug(`Device-mode after update attempt ${attempt}: ${JSON.stringify(actualNow)}`);
1327
1809
  const immediateVerification = verifyDeviceMode(resolvedDeviceMode, actualNow);
1328
1810
  if (immediateVerification.ok) {
@@ -1352,10 +1834,10 @@ async function applyDeviceMode(
1352
1834
  `Device-mode activation reboot did not come back within ${bootTimeout / 1000}s`,
1353
1835
  );
1354
1836
  }
1355
- await waitForDeviceModeApi(httpPort, bootTimeout);
1837
+ await waitForDeviceModeApi(httpPort, bootTimeout, authHeader);
1356
1838
  }
1357
1839
 
1358
- const actual = await readDeviceMode(httpPort);
1840
+ const actual = await readDeviceMode(httpPort, authHeader);
1359
1841
  log.debug(`Device-mode post-reboot: ${JSON.stringify(actual)}`);
1360
1842
  const verification = verifyDeviceMode(resolvedDeviceMode, actual);
1361
1843
  if (!verification.ok) {
@@ -1367,6 +1849,112 @@ async function applyDeviceMode(
1367
1849
  log.status(` Device-mode verified: ${formatDeviceModeSelection(resolvedDeviceMode)}`);
1368
1850
  }
1369
1851
 
1852
+ /** How long a creation waits for another to finish choosing its port block. The
1853
+ * critical section is a handful of loopback connect probes, so this is headroom for a
1854
+ * busy host, not an expected wait. */
1855
+ const ALLOCATION_LOCK_WAIT_MS = 60_000;
1856
+
1857
+ /** An explicit `portBase` is the caller's choice and is never moved: one that overlaps
1858
+ * another machine's block — persisted or still being created — is a `PORT_CONFLICT`
1859
+ * here rather than a `SPAWN_FAILED` from QEMU's bind later. */
1860
+ function assertPortBaseFree(
1861
+ portBase: number,
1862
+ name: string,
1863
+ machines: MachineState[],
1864
+ reserved: Array<{ name: string; portBase: number }>,
1865
+ ): void {
1866
+ const others = [
1867
+ ...machines.filter((m) => m.name !== name).map((m) => ({ name: m.name, portBase: m.portBase })),
1868
+ ...reserved,
1869
+ ];
1870
+ const clash = others.find((o) => Math.abs(o.portBase - portBase) < PORTS_PER_BLOCK);
1871
+ if (clash) {
1872
+ throw new QuickCHRError(
1873
+ "PORT_CONFLICT",
1874
+ `portBase ${portBase} overlaps machine "${clash.name}" (portBase ${clash.portBase}) — each machine takes a block of ${PORTS_PER_BLOCK} ports. Pick a base at least ${PORTS_PER_BLOCK} away, or omit portBase to have one allocated.`,
1875
+ );
1876
+ }
1877
+ }
1878
+
1879
+ /** Choose a new machine's port block and NIC MACs, avoiding every persisted machine
1880
+ * and every creation still in flight. Choosing is not claiming: only
1881
+ * `claimNewMachine()` makes the result safe from a concurrent creation. */
1882
+ async function pickNewMachineResources(
1883
+ name: string,
1884
+ opts: StartOptions,
1885
+ ): Promise<{ portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1886
+ const machines = loadAllMachines();
1887
+ const reserved = listReservations(name);
1888
+ if (opts.portBase) assertPortBaseFree(opts.portBase, name, machines, reserved);
1889
+ const usedBases = [...getUsedPortBases(), ...reserved.map((r) => r.portBase)];
1890
+ const portBase = opts.portBase || await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1891
+ validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, machines, name);
1892
+ const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
1893
+
1894
+ const usedMacs = getUsedMacs();
1895
+ for (const r of reserved) for (const mac of r.macs) usedMacs.add(mac);
1896
+ const networks = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), usedMacs);
1897
+ return { portBase, ports, networks };
1898
+ }
1899
+
1900
+ /** Choose a new machine's resources and take its machine lock with them recorded in
1901
+ * it, all while holding the data-dir allocation lock (#140).
1902
+ *
1903
+ * Without this, two concurrent creations both read the same machine list, both probe
1904
+ * the same free ports, and both pick the same block; one QEMU then fails its
1905
+ * `hostfwd` bind. Nothing persists the choice until `saveMachine()`, which for
1906
+ * `start()` is after the download and the spawn, so the choice itself has to be
1907
+ * visible: the machine lock carries it as a reservation, `listReservations()` reads
1908
+ * it, and it ends when the lock is released — by which time `machine.json` holds it,
1909
+ * or the creation failed and there is nothing left to hold. A crashed creator's
1910
+ * reservation dies with its pid.
1911
+ *
1912
+ * The machine lock is fail-fast. A live holder is another create of the same name. */
1913
+ export async function claimNewMachine(
1914
+ name: string,
1915
+ op: Extract<LockOperation, "add" | "start" | "foreground">,
1916
+ opts: StartOptions,
1917
+ ): Promise<{ lock: HeldLock; portBase: number; ports: Record<string, PortMapping>; networks: NetworkConfig[] }> {
1918
+ ensureDir(getDataDir());
1919
+ const allocation = await acquireLock(allocationLockPath(), "allocate", {
1920
+ waitMs: ALLOCATION_LOCK_WAIT_MS,
1921
+ subject: "The port allocator",
1922
+ });
1923
+ try {
1924
+ const picked = await pickNewMachineResources(name, opts);
1925
+ const machineDir = getMachineDir(name);
1926
+ const lockOpts = {
1927
+ ...machineLockOptions(name, machineDir),
1928
+ reserve: {
1929
+ portBase: picked.portBase,
1930
+ macs: picked.networks.flatMap((n) => (n.mac ? [n.mac] : [])),
1931
+ },
1932
+ };
1933
+ // The lock lives in the directory, so the directory comes first. A refusal here
1934
+ // means a live holder owns the directory — a create of the same name, or a
1935
+ // remove — and it, not this call, cleans it up. A remove renames the directory
1936
+ // away in one step; if that lands between the two lines, make a fresh one.
1937
+ let lock: HeldLock | undefined;
1938
+ for (let tries = 0; !lock; tries++) {
1939
+ ensureDir(machineDir);
1940
+ try {
1941
+ lock = acquireLockNow(machineLockPath(machineDir), op, lockOpts);
1942
+ } catch (e) {
1943
+ if (!(e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") || tries >= 2) throw e;
1944
+ }
1945
+ }
1946
+ // Another create of this name may have finished between the caller's existence
1947
+ // check and this lock.
1948
+ if (existsSync(join(machineDir, "machine.json"))) {
1949
+ lock.release();
1950
+ throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" was created by another process while this one was starting.`);
1951
+ }
1952
+ return { lock, ...picked };
1953
+ } finally {
1954
+ allocation.release();
1955
+ }
1956
+ }
1957
+
1370
1958
  /**
1371
1959
  * Main entry point for managing MikroTik CHR virtual machines via QEMU.
1372
1960
  *
@@ -1389,10 +1977,10 @@ export class QuickCHR {
1389
1977
  * `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
1390
1978
  static async add(opts: StartOptions = {}): Promise<MachineState> {
1391
1979
  opts = normalizeStartOptions(opts);
1392
- if (opts.name?.startsWith("-")) {
1393
- throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
1394
- }
1980
+ // add() always creates, so the full name rules apply before any I/O.
1981
+ if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
1395
1982
 
1983
+ warnVersionWithChannel(opts, createLogger(opts.onProgress));
1396
1984
  let version: string;
1397
1985
  if (opts.version) {
1398
1986
  if (!isValidVersion(opts.version)) {
@@ -1405,13 +1993,10 @@ export class QuickCHR {
1405
1993
  }
1406
1994
 
1407
1995
  const arch: Arch = resolveArch(opts.arch);
1408
- requireQemu(arch);
1409
- if (arch === "arm64") requireFirmware();
1410
- const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1996
+ assertRouterOs6Arch(version, arch);
1411
1997
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
1412
1998
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1413
-
1414
- if (hasProvisioningMutations({
1999
+ const mutations = {
1415
2000
  installAllPackages: opts.installAllPackages,
1416
2001
  packages: opts.packages,
1417
2002
  hasDeviceModeProvisioning,
@@ -1419,34 +2004,33 @@ export class QuickCHR {
1419
2004
  disableAdmin: opts.disableAdmin,
1420
2005
  license: opts.license,
1421
2006
  secureLogin: opts.secureLogin,
1422
- })) {
1423
- assertProvisioningSupportedVersion(version, describeProvisioningOperation({
1424
- installAllPackages: opts.installAllPackages,
1425
- packages: opts.packages,
1426
- hasDeviceModeProvisioning,
1427
- user: opts.user,
1428
- disableAdmin: opts.disableAdmin,
1429
- license: opts.license,
1430
- secureLogin: opts.secureLogin,
1431
- }));
2007
+ };
2008
+ // Version refusals before host checks: a request that can never succeed should
2009
+ // say why, not first ask for QEMU to be installed.
2010
+ if (hasProvisioningMutations(mutations)) {
2011
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
1432
2012
  }
2013
+ requireQemu(arch);
2014
+ if (arch === "arm64") requireFirmware();
2015
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1433
2016
 
1434
2017
  const existingNames = listMachineNames();
1435
2018
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
1436
2019
  if (existingNames.includes(name)) {
1437
- throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
2020
+ // A directory with no readable machine.json is a half-created machine, not a
2021
+ // machine — `list` cannot show it and `start` cannot boot it, so saying
2022
+ // "already exists" sends the user looking for something that is not there (#155).
2023
+ throw isOrphanMachineDir(name)
2024
+ ? new QuickCHRError(
2025
+ "MACHINE_EXISTS",
2026
+ `Machine "${name}" has a leftover directory with no readable machine.json — a create that did not finish. Clear it with 'quickchr remove ${name}', then retry.`,
2027
+ )
2028
+ : new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
1438
2029
  }
1439
2030
 
1440
- const usedBases = getUsedPortBases();
1441
- const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
1442
- validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
1443
- const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
1444
-
1445
2031
  const machineDir = getMachineDir(name);
1446
2032
  assertSufficientQuickchrStorage(`prepare CHR ${name}`);
1447
- ensureDir(machineDir);
1448
- const lockPath = join(machineDir, ".start-lock");
1449
- acquireLock(lockPath);
2033
+ const { lock, portBase, ports, networks } = await claimNewMachine(name, "add", opts);
1450
2034
  try {
1451
2035
  const cachedImg = await ensureCachedImage(version, arch);
1452
2036
  copyImageToMachine(cachedImg, machineDir);
@@ -1463,7 +2047,7 @@ export class QuickCHR {
1463
2047
  arch,
1464
2048
  cpu: opts.cpu ?? 1,
1465
2049
  mem: defaultMem(arch, opts.mem),
1466
- networks: resolveStartNetworks(opts.networks, opts.network),
2050
+ networks,
1467
2051
  ports,
1468
2052
  packages: opts.packages ?? [],
1469
2053
  installAllPackages: opts.installAllPackages,
@@ -1483,8 +2067,22 @@ export class QuickCHR {
1483
2067
  };
1484
2068
  saveMachine(state);
1485
2069
  return state;
2070
+ } catch (err) {
2071
+ // A failed create must leave nothing behind: the directory exists from this
2072
+ // call's ensureDir(), and without readable state it is invisible to `list`
2073
+ // and unremovable by `remove` while still blocking re-add (#155).
2074
+ //
2075
+ // The test is "no *readable* machine.json", not "no machine.json" — a
2076
+ // saveMachine() that died mid-write (disk full) leaves a truncated file, and
2077
+ // a file-exists check would take that for a finished machine and strand the
2078
+ // directory it was supposed to clean up. Nothing runs after saveMachine()
2079
+ // but `return state`, so a readable file here means the create succeeded.
2080
+ if (isOrphanMachineDir(name)) {
2081
+ try { removeState(name); } catch { /* best effort */ }
2082
+ }
2083
+ throw err;
1486
2084
  } finally {
1487
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2085
+ lock.release();
1488
2086
  }
1489
2087
  }
1490
2088
 
@@ -1492,20 +2090,36 @@ export class QuickCHR {
1492
2090
  * Start a new or existing CHR instance.
1493
2091
  *
1494
2092
  * The returned {@link ChrInstance} is REST-ready: all provisioning (packages, license,
1495
- * device-mode, user) has completed before this promise resolves.
2093
+ * device-mode, user) has completed before this promise resolves. Two exceptions:
2094
+ * `wait: false` resolves once QEMU is running and leaves readiness to the caller, and
2095
+ * a RouterOS 6 guest (boot-only, no REST) is ready when its native API answers a login.
1496
2096
  * Callers do not need to call {@link ChrInstance.waitForBoot} again unless they have
1497
2097
  * stopped and restarted the instance manually. Disk options (`bootSize`, `extraDisks`)
1498
2098
  * apply when creating a new machine and require `qemu-img` on the host.
1499
2099
  */
1500
2100
  static async start(opts: StartOptions = {}): Promise<ChrInstance> {
1501
2101
  opts = normalizeStartOptions(opts);
1502
- // Validate name early (before any I/O) so callers get a fast, clear error
2102
+ // Cheap guard before any I/O: a name that reads as a flag is always a mistake.
2103
+ // The full charset rules are applied below, but only when this call creates the
2104
+ // machine — a machine named under the older, looser rules must still start.
1503
2105
  if (opts.name?.startsWith("-")) {
1504
- throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
2106
+ throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
2107
+ }
2108
+
2109
+ if (opts.wait === false && opts.background === false) {
2110
+ throw new QuickCHRError(
2111
+ "INVALID_ARGUMENT",
2112
+ "--no-wait (wait: false) cannot be combined with --fg (background: false): a foreground start holds the serial console until QEMU exits, so there is nothing to return early from.",
2113
+ );
1505
2114
  }
1506
2115
 
1507
2116
  const logger = createLogger(opts.onProgress);
1508
2117
 
2118
+ // Before any download or disk work: a named socket whose two ends are already
2119
+ // taken cannot carry this machine, and #156's rule is that a start that cannot
2120
+ // succeed should not first fetch 43 MB.
2121
+ assertNamedSocketsHaveRoom(opts, logger);
2122
+
1509
2123
  const requestedDeviceMode = opts.deviceMode;
1510
2124
  const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
1511
2125
  for (const warning of resolvedDeviceMode.warnings) {
@@ -1514,6 +2128,7 @@ export class QuickCHR {
1514
2128
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1515
2129
 
1516
2130
  // Resolve version
2131
+ warnVersionWithChannel(opts, logger);
1517
2132
  let version: string;
1518
2133
  if (opts.version) {
1519
2134
  if ((CHANNELS as readonly string[]).includes(opts.version)) {
@@ -1531,7 +2146,7 @@ export class QuickCHR {
1531
2146
  version = await resolveVersion(channel);
1532
2147
  }
1533
2148
 
1534
- if (hasProvisioningMutations({
2149
+ const mutations = {
1535
2150
  installAllPackages: opts.installAllPackages,
1536
2151
  packages: opts.packages,
1537
2152
  hasDeviceModeProvisioning,
@@ -1539,51 +2154,69 @@ export class QuickCHR {
1539
2154
  disableAdmin: opts.disableAdmin,
1540
2155
  license: opts.license,
1541
2156
  secureLogin: opts.secureLogin,
1542
- })) {
1543
- assertProvisioningSupportedVersion(version, describeProvisioningOperation({
1544
- installAllPackages: opts.installAllPackages,
1545
- packages: opts.packages,
1546
- hasDeviceModeProvisioning,
1547
- user: opts.user,
1548
- disableAdmin: opts.disableAdmin,
1549
- license: opts.license,
1550
- secureLogin: opts.secureLogin,
1551
- }));
2157
+ };
2158
+ if (hasProvisioningMutations(mutations)) {
2159
+ assertProvisioningSupportedVersion(version, describeProvisioningOperation(mutations));
2160
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(mutations));
1552
2161
  }
1553
2162
 
1554
2163
  // Resolve architecture
1555
2164
  const arch: Arch = resolveArch(opts.arch);
1556
- const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
1557
-
1558
- // Check prerequisites (skip for dry-run — no QEMU needed)
1559
- if (!opts.dryRun) {
1560
- requireQemu(arch);
1561
- if (arch === "arm64") {
1562
- requireFirmware();
1563
- }
1564
- }
2165
+ // Host prerequisites are checked just before each launch below, after every
2166
+ // refusal: a request that cannot succeed says why before asking for QEMU.
2167
+ const requireHostFor = (target: Arch) => {
2168
+ requireQemu(target);
2169
+ if (target === "arm64") requireFirmware();
2170
+ };
1565
2171
 
1566
2172
  // Resolve name
1567
2173
  const existingNames = listMachineNames();
1568
2174
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
2175
+ // Only a *new* name has to satisfy the current rules — see the guard at the top.
2176
+ if (!existingNames.includes(name)) assertValidResourceName(name, "machine");
1569
2177
 
1570
2178
  // Check if machine already exists
1571
2179
  const existing = loadMachine(name);
1572
2180
  if (existing) {
2181
+ // Both paths below re-launch a guest quickchr did not just create, so neither
2182
+ // can run provisioning. Refuse before either of them: dropping the options in
2183
+ // silence is what #176 removes, and the running-machine path is the quieter of
2184
+ // the two — it returns a handle without so much as a boot.
2185
+ const request = {
2186
+ installAllPackages: opts.installAllPackages,
2187
+ packages: opts.packages,
2188
+ deviceMode: opts.deviceMode,
2189
+ user: opts.user,
2190
+ disableAdmin: opts.disableAdmin,
2191
+ license: opts.license,
2192
+ secureLogin: opts.secureLogin,
2193
+ };
2194
+ // Version before window: on a version that can never be provisioned, the
2195
+ // window refusal's `quickchr clean` route leads to a second refusal (#182).
2196
+ if (hasProvisioningRequest(request)) {
2197
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation({
2198
+ ...request,
2199
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(request.deviceMode)),
2200
+ }));
2201
+ }
2202
+ const satisfied = assertProvisioningWindow(existing, request);
2203
+ for (const ask of satisfied) {
2204
+ (logger ?? createLogger()).status(`${ask.description} is already applied — nothing to do.`);
2205
+ }
1573
2206
  if (isMachineRunning(existing)) {
1574
2207
  return createInstance(existing);
1575
2208
  }
1576
2209
  // First boot of add()-created machine: apply pending provisioning from stored state
1577
- if (!existing.lastStartedAt) {
1578
- const pendingOpts = {
1579
- installAllPackages: opts.installAllPackages ?? existing.installAllPackages,
1580
- packages: opts.packages?.length ? opts.packages : (existing.packages.length > 0 ? existing.packages : undefined),
1581
- deviceMode: opts.deviceMode ?? existing.deviceMode,
1582
- user: opts.user ?? existing.user,
1583
- disableAdmin: opts.disableAdmin ?? existing.disableAdmin,
2210
+ if (isProvisioningWindowOpen(existing)) {
2211
+ const pendingOpts = pendingProvisioningFor(existing, {
2212
+ installAllPackages: opts.installAllPackages,
2213
+ packages: opts.packages,
2214
+ deviceMode: opts.deviceMode,
2215
+ user: opts.user,
2216
+ disableAdmin: opts.disableAdmin,
1584
2217
  license: opts.license,
1585
- secureLogin: opts.secureLogin ?? existing.secureLogin,
1586
- };
2218
+ secureLogin: opts.secureLogin,
2219
+ });
1587
2220
  const hasPending = !!(
1588
2221
  pendingOpts.installAllPackages ||
1589
2222
  (pendingOpts.packages?.length ?? 0) > 0 ||
@@ -1594,49 +2227,43 @@ export class QuickCHR {
1594
2227
  pendingOpts.secureLogin === true
1595
2228
  );
1596
2229
  if (hasPending) {
2230
+ // Before the boot, not after it: `_provisionInstance()` re-checks the
2231
+ // version, but only once the guest is up — on RouterOS 6 that is a
2232
+ // download-free boot spent on a refusal known in advance.
2233
+ const pendingMutations = {
2234
+ ...pendingOpts,
2235
+ hasDeviceModeProvisioning: shouldApplyDeviceMode(resolveDeviceModeOptions(pendingOpts.deviceMode)),
2236
+ };
2237
+ assertProvisioningSupportedVersion(existing.version, describeProvisioningOperation(pendingMutations));
2238
+ assertWaitAllowsProvisioning(opts, describeProvisioningOperation(pendingMutations), existing.name);
2239
+ requireHostFor(existing.arch);
1597
2240
  return QuickCHR._launchExisting(existing, opts.background ?? true, pendingOpts, logger);
1598
2241
  }
1599
2242
  }
1600
2243
  // Exists but stopped — restart it
1601
- return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger);
2244
+ requireHostFor(existing.arch);
2245
+ return QuickCHR._launchExisting(existing, opts.background ?? true, undefined, logger, opts.wait ?? true);
1602
2246
  }
1603
2247
 
1604
- // Allocate port block
1605
- const usedBases = getUsedPortBases();
1606
- const portBase = opts.portBase
1607
- ? opts.portBase
1608
- : await findAvailablePortBlock(
1609
- usedBases,
1610
- opts.excludePorts,
1611
- opts.extraPorts,
1612
- );
1613
-
1614
- validateExplicitExtraPorts(
1615
- opts.extraPorts,
1616
- portBase,
1617
- opts.excludePorts,
1618
- loadAllMachines(),
1619
- name,
1620
- );
1621
-
1622
- const ports = buildPortMappings(
1623
- portBase,
1624
- opts.excludePorts,
1625
- opts.extraPorts,
1626
- );
2248
+ assertRouterOs6Arch(version, arch);
2249
+ const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
2250
+ if (!opts.dryRun) requireHostFor(arch);
1627
2251
 
1628
- // Dry run — return instance handle without actually spawning
2252
+ // Dry run — return instance handle without actually spawning. Nothing is
2253
+ // claimed: a dry run persists nothing for another create to collide with.
1629
2254
  if (opts.dryRun) {
1630
2255
  const machineDir = getMachineDir(name);
2256
+ const { portBase, ports, networks } = await pickNewMachineResources(name, opts);
1631
2257
  const state: MachineState = {
1632
2258
  name,
1633
2259
  version,
1634
2260
  arch,
1635
2261
  cpu: opts.cpu ?? 1,
1636
2262
  mem: defaultMem(arch, opts.mem),
1637
- networks: resolveStartNetworks(opts.networks, opts.network),
2263
+ networks,
1638
2264
  ports,
1639
2265
  packages: opts.packages ?? [],
2266
+ installAllPackages: opts.installAllPackages,
1640
2267
  deviceMode: requestedDeviceMode,
1641
2268
  user: opts.user,
1642
2269
  disableAdmin: opts.disableAdmin,
@@ -1654,29 +2281,10 @@ export class QuickCHR {
1654
2281
  return createInstance(state);
1655
2282
  }
1656
2283
 
1657
- // Acquire a lock to prevent concurrent starts of the same machine
1658
2284
  const machineDir = getMachineDir(name);
1659
2285
  assertSufficientQuickchrStorage(`start CHR ${name}`);
1660
- ensureDir(machineDir);
1661
- const lockPath = join(machineDir, ".start-lock");
1662
- acquireLock(lockPath);
1663
- try {
1664
-
1665
- // Download and prepare image
1666
- const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
1667
- copyImageToMachine(cachedImg, machineDir);
1668
-
1669
- // Prepare disks (boot resize + extra disks)
1670
- const diskArtifacts = await ensureConfiguredDisks(
1671
- machineDir,
1672
- diskOpts.bootSize,
1673
- diskOpts.extraDisks,
1674
- diskOpts.bootDiskFormat,
1675
- );
1676
- const bootDisk = diskArtifacts.bootDisk;
1677
- const extraDiskConfigs = diskArtifacts.extraDisks;
2286
+ const dirCreatedHere = !existsSync(machineDir);
1678
2287
 
1679
- // Build machine config
1680
2288
  const background = opts.background ?? true;
1681
2289
 
1682
2290
  // Provisioning includes any work that requires the machine to have booted first.
@@ -1693,7 +2301,33 @@ export class QuickCHR {
1693
2301
  opts.secureLogin === true
1694
2302
  );
1695
2303
 
1696
- const networkConfigs = resolveStartNetworks(opts.networks, opts.network);
2304
+ const { lock, portBase, ports, networks: networkConfigs } = await claimNewMachine(
2305
+ name,
2306
+ !background && !hasProvisioning ? "foreground" : "start",
2307
+ opts,
2308
+ );
2309
+ // Endpoints are claimed before QEMU is spawned, because resolution needs to know
2310
+ // which end this machine holds. A launch that fails before its state is persisted
2311
+ // has no instance lifecycle to release them later, so a failed start would leave a
2312
+ // machine holding an end of a link it never used — and two of those fill the link.
2313
+ // Cleared once saveMachine() has committed the claim.
2314
+ let unpersistedSocketClaim: MachineState | undefined;
2315
+ try {
2316
+
2317
+ // Download and prepare image
2318
+ const cachedImg = await ensureCachedImage(version, arch, undefined, logger);
2319
+ copyImageToMachine(cachedImg, machineDir);
2320
+
2321
+ // Prepare disks (boot resize + extra disks)
2322
+ const diskArtifacts = await ensureConfiguredDisks(
2323
+ machineDir,
2324
+ diskOpts.bootSize,
2325
+ diskOpts.extraDisks,
2326
+ diskOpts.bootDiskFormat,
2327
+ );
2328
+ const bootDisk = diskArtifacts.bootDisk;
2329
+ const extraDiskConfigs = diskArtifacts.extraDisks;
2330
+
1697
2331
  if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1698
2332
  throw new QuickCHRError(
1699
2333
  "NETWORK_UNAVAILABLE",
@@ -1712,6 +2346,7 @@ export class QuickCHR {
1712
2346
  networks: networkConfigs,
1713
2347
  ports,
1714
2348
  packages: opts.packages ?? [],
2349
+ installAllPackages: opts.installAllPackages,
1715
2350
  deviceMode: requestedDeviceMode,
1716
2351
  user: opts.user,
1717
2352
  disableAdmin: opts.disableAdmin,
@@ -1732,9 +2367,14 @@ export class QuickCHR {
1732
2367
  const accel = await detectAccel(arch);
1733
2368
  const note = accelNote(arch, accel);
1734
2369
  if (note) logger.warn(note);
1735
- registerSocketMembers(state);
1736
2370
  const hostfwd = buildHostfwdString(state.ports);
1737
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
2371
+ const resolvedNetworks = registerAndResolveNetworks(
2372
+ state,
2373
+ { platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
2374
+ hostfwd,
2375
+ );
2376
+ unpersistedSocketClaim = state;
2377
+ reportSocketTransports(state, logger);
1738
2378
 
1739
2379
  const launchConfig: QemuLaunchConfig = {
1740
2380
  arch,
@@ -1757,9 +2397,15 @@ export class QuickCHR {
1757
2397
 
1758
2398
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1759
2399
  if (!background && !hasProvisioning) {
2400
+ // QEMU has already exited here — spawnQemu() blocks in foreground. A machine
2401
+ // that is not running must not keep holding an end of a link: stop() and
2402
+ // remove() unregister, and this path reaches "stopped" without going through
2403
+ // either of them.
2404
+ unregisterSocketMembers(state);
1760
2405
  state.status = "stopped";
1761
2406
  state.lastStartedAt = new Date().toISOString();
1762
2407
  saveMachine(state);
2408
+ unpersistedSocketClaim = undefined;
1763
2409
  return createInstance(state);
1764
2410
  }
1765
2411
 
@@ -1767,8 +2413,10 @@ export class QuickCHR {
1767
2413
  state.lastStartedAt = new Date().toISOString();
1768
2414
 
1769
2415
  saveMachine(state);
2416
+ unpersistedSocketClaim = undefined;
1770
2417
 
1771
2418
  const instance = createInstance(state);
2419
+ if (opts.wait === false) return instance;
1772
2420
 
1773
2421
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1774
2422
 
@@ -1818,7 +2466,7 @@ export class QuickCHR {
1818
2466
  "BOOT_TIMEOUT",
1819
2467
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1820
2468
  (hasProvisioning ? " — provisioning could not run." : ".") +
1821
- await cleanupAfterBootFailure(instance, name, failure) +
2469
+ await cleanupAfterBootFailure(instance, name, failure, { kind: "remove-new" }) +
1822
2470
  `\n${failure.summary}`,
1823
2471
  );
1824
2472
  }
@@ -1842,15 +2490,20 @@ export class QuickCHR {
1842
2490
  } catch { /* never propagate */ }
1843
2491
 
1844
2492
  if (hasProvisioning) {
1845
- await QuickCHR._provisionInstance(instance, state, {
1846
- installAllPackages: opts.installAllPackages,
1847
- packages: opts.packages,
1848
- deviceMode: requestedDeviceMode,
1849
- user: opts.user,
1850
- disableAdmin: opts.disableAdmin,
1851
- license: opts.license,
1852
- secureLogin: opts.secureLogin,
1853
- }, launchConfig, logger);
2493
+ try {
2494
+ await QuickCHR._provisionInstance(instance, state, {
2495
+ installAllPackages: opts.installAllPackages,
2496
+ packages: opts.packages,
2497
+ deviceMode: requestedDeviceMode,
2498
+ user: opts.user,
2499
+ disableAdmin: opts.disableAdmin,
2500
+ license: opts.license,
2501
+ secureLogin: opts.secureLogin,
2502
+ }, launchConfig, logger);
2503
+ } catch (err) {
2504
+ // This call created the machine, so a failed run takes it away again.
2505
+ throw await failProvisioning(err, instance, undefined);
2506
+ }
1854
2507
  }
1855
2508
 
1856
2509
  // Foreground + provisioning: all provisioning is done in background mode.
@@ -1858,25 +2511,34 @@ export class QuickCHR {
1858
2511
  // QEMU mux console (Ctrl-A X to quit, Ctrl-A C for monitor — standard QEMU
1859
2512
  // shortcuts that are well-documented and googleable).
1860
2513
  if (!background && hasProvisioning) {
1861
- await instance.stop();
2514
+ await stopUnderLock(state);
1862
2515
  // Brief pause for QEMU to flush and release the disk image
1863
2516
  await Bun.sleep(1000);
1864
- // Release lock before re-launching — _launchExisting acquires its own.
1865
- // The outer finally block will attempt a second unlink; that is harmless.
1866
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2517
+ // Release the lock before re-launching — _launchExisting acquires its own.
2518
+ // The outer finally releases again, which is a no-op.
2519
+ lock.release();
1867
2520
  return QuickCHR._launchExisting(state, false, undefined, logger);
1868
2521
  }
1869
2522
 
1870
2523
  return instance;
1871
2524
  } catch (err) {
1872
- // Clean up orphaned machine directory if spawn failed before machine.json was saved
1873
- const machineJsonPath = join(machineDir, "machine.json");
1874
- if (!existsSync(machineJsonPath)) {
1875
- try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
2525
+ // Release an endpoint this launch claimed but never committed. stop()/remove()
2526
+ // would do it for a machine that reached persisted state; one that did not has
2527
+ // no instance to do it.
2528
+ if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2529
+ // Clean up an orphaned machine directory if the spawn failed before readable
2530
+ // state was saved — same predicate as add()'s cleanup, so a truncated
2531
+ // machine.json is not mistaken for a finished machine.
2532
+ //
2533
+ // `dirCreatedHere` is the difference from add(): start() also starts machines
2534
+ // that already exist, and a directory this call did not create is never ours
2535
+ // to delete, whatever state it is in.
2536
+ if (dirCreatedHere && isOrphanMachineDir(name)) {
2537
+ try { removeState(name); } catch { /* best effort */ }
1876
2538
  }
1877
2539
  throw err;
1878
2540
  } finally {
1879
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2541
+ lock.release();
1880
2542
  }
1881
2543
  }
1882
2544
 
@@ -1898,6 +2560,9 @@ export class QuickCHR {
1898
2560
  logger?: ProgressLogger,
1899
2561
  ): Promise<void> {
1900
2562
  const log = logger ?? createLogger();
2563
+ // What actually ran, persisted at the end as MachineState.provisioning — the
2564
+ // record that says this guest has been provisioned, and with what (#176).
2565
+ const applied: ProvisioningStep[] = [];
1901
2566
  const resolvedDeviceMode = resolveDeviceModeOptions(opts.deviceMode);
1902
2567
  const hasDeviceModeProvisioning = shouldApplyDeviceMode(resolvedDeviceMode);
1903
2568
  assertProvisioningSupportedVersion(machineState.version, describeProvisioningOperation({
@@ -1928,6 +2593,7 @@ export class QuickCHR {
1928
2593
  saveMachine(current);
1929
2594
  }
1930
2595
  machineState.packages = installed;
2596
+ applied.push("packages");
1931
2597
  } else if (opts.packages && opts.packages.length > 0) {
1932
2598
  const installed = await installPackages(opts.packages, machineState.version, machineState.arch, chrPorts.ssh, chrPorts.http, log);
1933
2599
  await waitForBootWithProgress(instance, bootTimeout, log, " Waiting for CHR to reboot after package installation...");
@@ -1937,10 +2603,14 @@ export class QuickCHR {
1937
2603
  saveMachine(current);
1938
2604
  }
1939
2605
  machineState.packages = installed;
2606
+ applied.push("packages");
1940
2607
  }
1941
2608
 
1942
2609
  if (hasDeviceModeProvisioning) {
1943
- await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log);
2610
+ // Factory admin on purpose: this runs at step 2 and the user step is step 4,
2611
+ // so `machineState.user` names an account that does not exist yet.
2612
+ await applyDeviceMode(instance, machineState, resolvedDeviceMode, launchConfig, log, FACTORY_AUTH_HEADER);
2613
+ applied.push("deviceMode");
1944
2614
  }
1945
2615
 
1946
2616
  if (opts.license) {
@@ -1969,6 +2639,7 @@ export class QuickCHR {
1969
2639
  saveMachine(current);
1970
2640
  }
1971
2641
  machineState.licenseLevel = actualLevel as LicenseLevel;
2642
+ applied.push("license");
1972
2643
  log.status(` License applied: free → ${actualLevel}`);
1973
2644
  } catch (e) {
1974
2645
  if (e instanceof QuickCHRError) throw e;
@@ -1980,6 +2651,9 @@ export class QuickCHR {
1980
2651
  }
1981
2652
 
1982
2653
  if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
2654
+ if (opts.user) applied.push("user");
2655
+ if (opts.disableAdmin) applied.push("disableAdmin");
2656
+ if (opts.secureLogin === true) applied.push("secureLogin");
1983
2657
  const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
1984
2658
  if (result.user || result.managedSshKey) {
1985
2659
  // Persist user info + managed SSH key fact in state (password placeholder —
@@ -2007,6 +2681,11 @@ export class QuickCHR {
2007
2681
  }
2008
2682
  }
2009
2683
  }
2684
+
2685
+ // Stamped only here, after every requested step returned. A run that throws
2686
+ // leaves the record absent, and the caller rolls the whole run back
2687
+ // (`rollbackFailedProvisioning()`), so the retry starts from a factory disk.
2688
+ recordProvisioning(machineState, applied);
2010
2689
  }
2011
2690
 
2012
2691
  /** Re-launch an existing stopped machine. */
@@ -2023,11 +2702,47 @@ export class QuickCHR {
2023
2702
  secureLogin?: boolean;
2024
2703
  },
2025
2704
  logger?: ProgressLogger,
2705
+ wait = true,
2026
2706
  ): Promise<ChrInstance> {
2027
- const lockPath = join(state.machineDir, ".start-lock");
2028
- acquireLock(lockPath);
2707
+ const wantsProvisioning = (opts: typeof provisioningOpts) => !!(
2708
+ opts && (
2709
+ opts.installAllPackages ||
2710
+ (opts.packages?.length ?? 0) > 0 ||
2711
+ opts.deviceMode ||
2712
+ opts.user ||
2713
+ opts.disableAdmin ||
2714
+ opts.license ||
2715
+ opts.secureLogin === true
2716
+ )
2717
+ );
2718
+ let hasProvisioning = wantsProvisioning(provisioningOpts);
2719
+ const lock = acquireLockNow(
2720
+ machineLockPath(state.machineDir),
2721
+ !background && !hasProvisioning ? "foreground" : "start",
2722
+ machineLockOptions(state.name, state.machineDir),
2723
+ );
2724
+ // Same pre-persistence window as start(): a relaunch that claims an endpoint and
2725
+ // then fails to spawn would keep it, with no instance to release it.
2726
+ let unpersistedSocketClaim: MachineState | undefined;
2029
2727
  try {
2030
2728
 
2729
+ // `state` is the caller's snapshot, read before the lock was taken; everything
2730
+ // below acts on it, `state.pid` included (#180). Re-read it, and if another
2731
+ // start got the machine running in between, hand back that one rather than
2732
+ // booting a second QEMU on the same disk.
2733
+ assertSameMachine(state);
2734
+ refreshMachineState(state);
2735
+ if (isMachineRunning(state)) return createInstance(state);
2736
+ // `provisioningOpts` was worked out from that snapshot too. If another start
2737
+ // booted the machine in between, the window is shut and running them would be
2738
+ // a second first boot: check them against the machine as it is now, and drop
2739
+ // whatever that start already applied.
2740
+ if (provisioningOpts && !isProvisioningWindowOpen(state)) {
2741
+ assertProvisioningWindow(state, provisioningOpts);
2742
+ provisioningOpts = undefined;
2743
+ hasProvisioning = false;
2744
+ }
2745
+
2031
2746
  const diskPath = join(state.machineDir, "disk.img");
2032
2747
  if (!existsSync(diskPath)) {
2033
2748
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${state.name}"`);
@@ -2042,27 +2757,30 @@ export class QuickCHR {
2042
2757
  const bootDisk = diskArtifacts.bootDisk;
2043
2758
  const extraDiskConfigs = diskArtifacts.extraDisks;
2044
2759
 
2045
- const hasProvisioning = !!(
2046
- provisioningOpts && (
2047
- provisioningOpts.installAllPackages ||
2048
- (provisioningOpts.packages?.length ?? 0) > 0 ||
2049
- provisioningOpts.deviceMode ||
2050
- provisioningOpts.user ||
2051
- provisioningOpts.disableAdmin ||
2052
- provisioningOpts.license ||
2053
- provisioningOpts.secureLogin === true
2054
- )
2055
- );
2056
2760
  // Always boot in background when provisioning is needed
2057
2761
  const spawnBackground = hasProvisioning ? true : background;
2762
+ // The record a failed first boot rolls back to — taken before this launch writes
2763
+ // pid, status or lastStartedAt into `state`. Whenever the window is open, not only
2764
+ // when there is provisioning to run: a boot timeout on an add()-created machine is
2765
+ // the same failed first boot as a provisioning error, and resets the same way
2766
+ // instead of deleting what `add` recorded (#182).
2767
+ const beforeLaunch = isProvisioningWindowOpen(state) ? structuredClone(state) : undefined;
2768
+ const bootFailureCleanup: BootFailureCleanup = beforeLaunch
2769
+ ? { kind: "reset-first-boot", restore: beforeLaunch }
2770
+ : { kind: "preserve-existing" };
2058
2771
 
2059
2772
  const platform = await detectPlatform();
2060
2773
  const accel = await detectAccel(state.arch);
2061
2774
  const note = accelNote(state.arch, accel);
2062
2775
  if (note) (logger ?? createLogger()).warn(note);
2063
- registerSocketMembers(state);
2064
2776
  const hostfwd = buildHostfwdString(state.ports);
2065
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
2777
+ const resolvedNetworks = registerAndResolveNetworks(
2778
+ state,
2779
+ { platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
2780
+ hostfwd,
2781
+ );
2782
+ unpersistedSocketClaim = state;
2783
+ reportSocketTransports(state, logger ?? createLogger());
2066
2784
 
2067
2785
  const launchConfig: QemuLaunchConfig = {
2068
2786
  arch: state.arch,
@@ -2085,9 +2803,12 @@ export class QuickCHR {
2085
2803
 
2086
2804
  // Foreground without provisioning: spawnQemu blocks until QEMU exits
2087
2805
  if (!background && !hasProvisioning) {
2806
+ // See the same branch in start(): QEMU has exited, so the endpoint goes back.
2807
+ unregisterSocketMembers(state);
2088
2808
  state.status = "stopped";
2089
2809
  state.lastStartedAt = new Date().toISOString();
2090
2810
  saveMachine(state);
2811
+ unpersistedSocketClaim = undefined;
2091
2812
  return createInstance(state);
2092
2813
  }
2093
2814
 
@@ -2095,8 +2816,10 @@ export class QuickCHR {
2095
2816
  state.status = "running";
2096
2817
  state.lastStartedAt = new Date().toISOString();
2097
2818
  saveMachine(state);
2819
+ unpersistedSocketClaim = undefined;
2098
2820
 
2099
2821
  const instance = createInstance(state);
2822
+ if (!wait) return instance;
2100
2823
 
2101
2824
  // Always wait for boot in background mode — start() promises "REST-ready".
2102
2825
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
@@ -2126,7 +2849,7 @@ export class QuickCHR {
2126
2849
  "BOOT_TIMEOUT",
2127
2850
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
2128
2851
  (hasProvisioning ? " — provisioning could not run." : ".") +
2129
- await cleanupAfterBootFailure(instance, state.name, failure) +
2852
+ await cleanupAfterBootFailure(instance, state.name, failure, bootFailureCleanup) +
2130
2853
  `\n${failure.summary}`,
2131
2854
  );
2132
2855
  }
@@ -2148,7 +2871,11 @@ export class QuickCHR {
2148
2871
  } catch { /* never propagate */ }
2149
2872
 
2150
2873
  if (hasProvisioning && provisioningOpts) {
2151
- await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2874
+ try {
2875
+ await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
2876
+ } catch (err) {
2877
+ throw await failProvisioning(err, instance, beforeLaunch);
2878
+ }
2152
2879
  }
2153
2880
 
2154
2881
  try {
@@ -2160,8 +2887,11 @@ export class QuickCHR {
2160
2887
  } catch { /* never propagate */ }
2161
2888
 
2162
2889
  return instance;
2890
+ } catch (err) {
2891
+ if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2892
+ throw err;
2163
2893
  } finally {
2164
- try { unlinkSync(lockPath); } catch { /* ignore */ }
2894
+ lock.release();
2165
2895
  }
2166
2896
  }
2167
2897
 
@@ -2185,6 +2915,54 @@ export class QuickCHR {
2185
2915
  return createInstance(state);
2186
2916
  }
2187
2917
 
2918
+ /** Machines whose `machine.json` is present but cannot be turned into state, each
2919
+ * with the reason: the file would not read, the JSON is invalid, or it parsed into
2920
+ * something that is not a machine.
2921
+ *
2922
+ * `list()` skips these so one corrupt file cannot abort the enumeration; this is
2923
+ * where they stay visible. `get(name)` still throws for such a machine — a lookup
2924
+ * by name is not an enumeration (#165). */
2925
+ static listUnreadable(): Array<{ name: string; error: string }> {
2926
+ return listUnreadableMachines();
2927
+ }
2928
+
2929
+ /** Names of machine directories with no readable `machine.json` — half-created
2930
+ * machines that `list()` cannot show and `get()` cannot resolve (#155). */
2931
+ static listOrphans(): string[] {
2932
+ return listOrphanMachineDirs();
2933
+ }
2934
+
2935
+ /** Delete a half-created machine directory. Returns false when `name` is a real
2936
+ * machine (use `get(name)?.remove()`) or does not exist at all.
2937
+ *
2938
+ * A crash or SIGKILL can strand a directory even with add()'s cleanup in place, and
2939
+ * a stranded directory blocks re-add while being invisible to `list` — so clearing
2940
+ * one must not require knowing that the data dir exists. */
2941
+ static removeOrphan(name: string): boolean {
2942
+ // Explicit, before the predicate: this deletes a directory tree, and `join()`
2943
+ // turns ".." into the data dir itself. Say why rather than answering `false`.
2944
+ assertPathSafeName(name, "machine");
2945
+ if (!isOrphanMachineDir(name)) return false;
2946
+
2947
+ // A create still in flight is indistinguishable from an orphan by directory
2948
+ // contents alone — add()/start() write machine.json last, so the window between
2949
+ // ensureDir() and saveMachine() looks exactly like a half-made machine. The
2950
+ // machine lock is what tells them apart, so take it: it throws MACHINE_LOCKED
2951
+ // while a live creator holds it, and takes over one whose owner is gone — which
2952
+ // is the case this recovery exists for. No waiting: a live creator means this is
2953
+ // not an orphan, and the answer is to let it finish.
2954
+ const lock = acquireLockNow(machineLockPath(getMachineDir(name)), "remove-orphan", machineLockOptions(name, getMachineDir(name)));
2955
+ try {
2956
+ // The creator may have finished between the check above and the lock.
2957
+ if (!isOrphanMachineDir(name)) return false;
2958
+ removeState(name);
2959
+ return true;
2960
+ } finally {
2961
+ // A no-op once the directory has gone; releases it if removal threw.
2962
+ lock.release();
2963
+ }
2964
+ }
2965
+
2188
2966
  /** Run doctor checks for prerequisites. */
2189
2967
  static async doctor(): Promise<DoctorResult> {
2190
2968
  const checks: DoctorResult["checks"] = [];
@@ -2373,29 +3151,20 @@ export class QuickCHR {
2373
3151
  }
2374
3152
 
2375
3153
  // Orphaned machine directories (have files but no machine.json)
2376
- const machinesDir = getMachinesDir();
2377
- if (existsSync(machinesDir)) {
2378
- const dirs = readdirSync(machinesDir, { withFileTypes: true })
2379
- .filter(e => e.isDirectory());
2380
- const orphans: string[] = [];
2381
- for (const dir of dirs) {
2382
- const mjPath = join(machinesDir, dir.name, "machine.json");
2383
- if (!existsSync(mjPath)) {
2384
- orphans.push(dir.name);
2385
- }
2386
- }
3154
+ if (existsSync(getMachinesDir())) {
3155
+ const orphans = listOrphanMachineDirs();
2387
3156
  if (orphans.length > 0) {
2388
- const removeLines = orphans.map((o) => ` rm -rf ${join(machinesDir, o)}`).join("\n");
3157
+ const removeLines = orphans.map((o) => ` quickchr remove ${o}`).join("\n");
2389
3158
  checks.push({
2390
3159
  label: "Orphaned machine dirs",
2391
3160
  status: "warn",
2392
- detail: `${orphans.length} dir(s) without machine.json: ${orphans.join(", ")}\nRemove manually:\n${removeLines}`,
3161
+ detail: `${orphans.length} dir(s) with no readable machine.json: ${orphans.join(", ")}\nClear them with:\n${removeLines}`,
2393
3162
  });
2394
3163
  } else {
2395
3164
  checks.push({
2396
3165
  label: "Machine state",
2397
3166
  status: "ok",
2398
- detail: `${dirs.length} machine(s), no orphans`,
3167
+ detail: `${listMachineNames().length} machine(s), no orphans`,
2399
3168
  });
2400
3169
  }
2401
3170
  }