@tikoci/quickchr 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/qemu.ts CHANGED
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
11
11
  import { QuickCHRError } from "./types.ts";
12
12
  import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
13
- import { buildHostfwdString } from "./network.ts";
13
+ import { buildHostfwdString, deviceArgs } from "./network.ts";
14
14
  import { ensureDir } from "./state.ts";
15
15
  import { restGet } from "./rest.ts";
16
16
  import { classifyProbeError, recordBootProbe, serialLogEnabled, type BootProbeStats } from "./diagnostics.ts";
@@ -137,8 +137,15 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
137
137
  return args;
138
138
  }
139
139
 
140
- /** Build networking arguments from NetworkConfig array. */
141
- function buildNetworkArgs(
140
+ /** Build networking arguments from a `NetworkConfig` array.
141
+ *
142
+ * Exported because it is one of the two places a NIC reaches QEMU (the other is
143
+ * `resolveAllNetworks()` in `network.ts`, whose output the `net.resolved` branch
144
+ * below passes straight through). Testing it through {@link buildQemuArgs} would
145
+ * require a QEMU binary on the host, which the unit tier does not have — and a
146
+ * test that skips itself when QEMU is missing would assert nothing on CI, which
147
+ * is how a MAC-less NIC got shipped in the first place (#154). */
148
+ export function buildNetworkArgs(
142
149
  args: string[],
143
150
  ports: Record<string, PortMapping>,
144
151
  networks: NetworkConfig[],
@@ -157,39 +164,39 @@ function buildNetworkArgs(
157
164
  if (spec === "vmnet-shared") {
158
165
  args.push(
159
166
  "-netdev", `vmnet-shared,id=${id}`,
160
- "-device", `virtio-net-pci,netdev=${id}`,
167
+ ...deviceArgs(net),
161
168
  );
162
169
  } else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
163
170
  args.push(
164
171
  "-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
165
- "-device", `virtio-net-pci,netdev=${id}`,
172
+ ...deviceArgs(net),
166
173
  );
167
174
  } else if (typeof spec === "object" && spec.type === "socket-listen") {
168
175
  args.push(
169
176
  "-netdev", `socket,id=${id},listen=:${spec.port}`,
170
- "-device", `virtio-net-pci,netdev=${id}`,
177
+ ...deviceArgs(net),
171
178
  );
172
179
  } else if (typeof spec === "object" && spec.type === "socket-connect") {
173
180
  args.push(
174
181
  "-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
175
- "-device", `virtio-net-pci,netdev=${id}`,
182
+ ...deviceArgs(net),
176
183
  );
177
184
  } else if (typeof spec === "object" && spec.type === "socket-mcast") {
178
185
  args.push(
179
186
  "-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
180
- "-device", `virtio-net-pci,netdev=${id}`,
187
+ ...deviceArgs(net),
181
188
  );
182
189
  } else if (typeof spec === "object" && spec.type === "tap") {
183
190
  args.push(
184
191
  "-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
185
- "-device", `virtio-net-pci,netdev=${id}`,
192
+ ...deviceArgs(net),
186
193
  );
187
194
  } else {
188
195
  // Default: user-mode networking with port forwarding (only on the first user NIC)
189
196
  const hostfwd = buildHostfwdString(ports);
190
197
  args.push(
191
198
  "-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
192
- "-device", `virtio-net-pci,netdev=${id}`,
199
+ ...deviceArgs(net),
193
200
  );
194
201
  }
195
202
  }
@@ -379,6 +386,20 @@ export async function spawnQemu(
379
386
  // After spawn, close parent's copy; child process inherits its own copy.
380
387
  const logFd = openSync(logPath, "a");
381
388
 
389
+ // A backgrounded QEMU outlives the CLI invocation that started it, so it must
390
+ // leave the caller's process group. `detached: true` is `setsid()` on POSIX;
391
+ // `unref()` alone is not enough, because it only covers a *voluntary* parent
392
+ // exit. It leaves QEMU in the caller's process group, where a group signal
393
+ // reaches it: Ctrl-C in the terminal, a shell `timeout`, a CI step teardown,
394
+ // an agent harness killing a stuck command (#159).
395
+ //
396
+ // Killing a run's VMs deliberately is by QEMU process name, not by group
397
+ // (`scripts/ci-file-watchdog.ts`, and the integration workflow's cleanup
398
+ // steps), so detaching costs no cleanup reach.
399
+ //
400
+ // Windows keeps its own spawn below for a different failure — a Job Object
401
+ // takes everything in it when the parent goes — and the two are not merged
402
+ // because only this one can be verified here.
382
403
  let spawnedPid: number;
383
404
 
384
405
  if (process.platform === "win32") {
@@ -394,6 +415,11 @@ export async function spawnQemu(
394
415
  stdio: ["ignore", logFd, logFd],
395
416
  windowsHide: true,
396
417
  });
418
+ // A failed exec surfaces asynchronously here, as an `error` event with no
419
+ // pid — and an unhandled `error` event is a crash, not a thrown
420
+ // QuickCHRError. Nothing awaits the child, so this listener is what keeps
421
+ // a qemu binary that is missing, or cannot be executed, reportable.
422
+ child.on("error", () => { /* surfaced by the liveness check below */ });
397
423
  closeSync(logFd);
398
424
  if (child.pid === undefined) {
399
425
  throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
@@ -402,13 +428,14 @@ export async function spawnQemu(
402
428
  spawnedPid = child.pid;
403
429
  } else {
404
430
  const proc = Bun.spawn(spawnCmd, {
431
+ detached: true,
405
432
  stdout: logFd,
406
433
  stderr: logFd,
407
434
  stdin: "ignore",
408
435
  });
409
436
  closeSync(logFd);
410
- // unref() lets the parent Bun process exit without waiting for QEMU.
411
- // QEMU becomes an orphan adopted by init/launchd and keeps running.
437
+ // unref() lets the parent exit without waiting for QEMU; detached keeps
438
+ // QEMU alive when the parent is signalled rather than exiting.
412
439
  proc.unref();
413
440
  spawnedPid = proc.pid;
414
441
  }
@@ -16,7 +16,9 @@ import type {
16
16
  LicenseInput,
17
17
  LicenseLevel,
18
18
  MachineState,
19
+ NetworkConfig,
19
20
  NetworkTopologyEntry,
21
+ PlatformInfo,
20
22
  PortMapping,
21
23
  QgaCommand,
22
24
  ServiceEndpoint,
@@ -24,7 +26,8 @@ import type {
24
26
  SshServiceEndpoint,
25
27
  StartOptions,
26
28
  } from "./types.ts";
27
- import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
29
+ import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION, HOST_GATEWAY_IP } from "./types.ts";
30
+ import { assertValidResourceName, assertPathSafeName } from "./names.ts";
28
31
  import packageJson from "../../package.json";
29
32
  import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, accelNote, resolveAccelOverrideWithSource, accelSourceLabel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
30
33
  import {
@@ -34,16 +37,20 @@ import {
34
37
  assertProvisioningSupportedVersion,
35
38
  PROVISIONING_FEATURE_LABEL,
36
39
  } from "./versions.ts";
37
- import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
40
+ import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, assignMacs, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts, describeSocketTransport } from "./network.ts";
38
41
  import {
39
42
  getUsedPortBases,
43
+ getUsedMacs,
40
44
  saveMachine,
41
45
  loadMachine,
42
46
  loadAllMachines,
47
+ listUnreadableMachines,
43
48
  removeMachine as removeState,
44
49
  getMachineDir,
45
50
  getMachinesDir,
46
51
  listMachineNames,
52
+ isOrphanMachineDir,
53
+ listOrphanMachineDirs,
47
54
  refreshAllStatuses,
48
55
  isMachineRunning,
49
56
  ensureDir,
@@ -68,7 +75,7 @@ import { restExecute } from "./exec.ts";
68
75
  import { qgaExec } from "./qga.ts";
69
76
  import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
70
77
  import { restRequest, restGet, restPost } from "./rest.ts";
71
- import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
78
+ import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
72
79
  import { createLogger, type ProgressLogger } from "./log.ts";
73
80
  import {
74
81
  formatDeviceModeSelection,
@@ -83,7 +90,7 @@ import type { LicenseOptions } from "./types.ts";
83
90
  import type { GuestExec } from "./guest-snapshot.ts";
84
91
  import { toChrPorts } from "./network.ts";
85
92
  import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
86
- import { existsSync, readdirSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
93
+ import { existsSync, rmSync, copyFileSync, writeFileSync, unlinkSync, openSync, writeSync, closeSync, readFileSync } from "node:fs";
87
94
  import { join } from "node:path";
88
95
 
89
96
  // --- Architecture-aware defaults ---
@@ -186,19 +193,97 @@ function getSocketNamedNetworks(state: MachineState): string[] {
186
193
  .map((n) => (n.specifier as { type: "socket"; name: string }).name);
187
194
  }
188
195
 
196
+ /** Refuse a start that a named socket has no room for, before any I/O.
197
+ *
198
+ * Only a *new* member is refused: a machine already holding an endpoint is simply
199
+ * restarting into its own slot. An unknown socket is left alone — `start()`
200
+ * auto-creates it further down. */
201
+ function assertNamedSocketsHaveRoom(opts: StartOptions, logger: ProgressLogger): void {
202
+ const named = resolveStartNetworks(opts.networks, opts.network)
203
+ .map((n) => n.specifier)
204
+ .filter((spec): spec is { type: "socket"; name: string } =>
205
+ typeof spec === "object" && spec !== null && spec.type === "socket");
206
+ for (const { name } of named) {
207
+ const entry = getNamedSocket(name);
208
+ if (!entry?.endpoints) continue;
209
+ if (opts.name && entry.endpoints.includes(opts.name)) continue;
210
+ if (entry.endpoints.includes(null)) continue;
211
+ const held = entry.endpoints.filter((m): m is string => m !== null);
212
+ logger.debug(`socket::${name} endpoints held by ${held.join(", ")}`);
213
+ throw new QuickCHRError(
214
+ "NETWORK_UNAVAILABLE",
215
+ `Named socket "${name}" is a ${entry.mode} link and carries 2 machines; ` +
216
+ `${held.join(" and ")} already hold both ends. ` +
217
+ `Stop one of them, or create an N-way segment with 'quickchr networks sockets create <name> --mode mcast'.`,
218
+ );
219
+ }
220
+ }
221
+
222
+ /** QEMU version for the binary this machine's arch will run on, when it can be read.
223
+ * `-netdev dgram` needs 7.2, and a named socket should say so rather than failing at
224
+ * spawn with a netdev QEMU does not recognize. */
225
+ function qemuVersionForArch(platform: PlatformInfo, arch: Arch): string | undefined {
226
+ const bin = arch === "arm64" ? platform.qemuBinArm64 : platform.qemuBinX86;
227
+ return bin ? getQemuVersion(bin) : undefined;
228
+ }
229
+
189
230
  function registerSocketMembers(state: MachineState): void {
190
231
  for (const name of getSocketNamedNetworks(state)) {
191
232
  try {
192
- if (!getNamedSocket(name)) {
193
- createNamedSocket(name, { autoCreated: true });
194
- }
195
- addSocketMember(name, state.name);
233
+ // Create-if-missing and join under one registry lock: two concurrent starts
234
+ // both pass a test-then-create, and both then claim the same endpoint slot.
235
+ joinNamedSocket(name, state.name, { autoCreated: true });
196
236
  } catch (e) {
237
+ // A full two-member link is a refusal, not a warning: carrying on would spawn
238
+ // QEMU with no endpoint to bind, and on a `dgram` link a second machine
239
+ // binding the same path unlinks the first one's socket and steals the link
240
+ // with nothing logged on either side.
241
+ if (e instanceof QuickCHRError && e.code === "NETWORK_UNAVAILABLE") throw e;
197
242
  console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
198
243
  }
199
244
  }
200
245
  }
201
246
 
247
+ /** Report the transport each named socket resolved to.
248
+ *
249
+ * #158's acceptance bar: nothing about a named socket should require opening a file
250
+ * under the data dir. The field report that produced these issues had to read
251
+ * `machine.json` to discover its link was UDP multicast, after ping had already
252
+ * failed silently. */
253
+ function reportSocketTransports(state: MachineState, logger: ProgressLogger): void {
254
+ for (const name of getSocketNamedNetworks(state)) {
255
+ const entry = getNamedSocket(name);
256
+ if (!entry) continue;
257
+ logger.status(` Network socket::${name}: ${describeSocketTransport(entry, getSocketSlot(entry, state.name))}`);
258
+ }
259
+ }
260
+
261
+ /** Claim each named socket's endpoint, then resolve — releasing the claims if
262
+ * resolution fails.
263
+ *
264
+ * Membership is persisted *before* networks resolve, because the resolver needs to
265
+ * know which end this machine holds. So a resolution that throws — a `dgram` link on
266
+ * Windows, or on a QEMU older than 7.2 — would otherwise leave the machine holding an
267
+ * endpoint it never used, and two failed starts would fill a link with machines that
268
+ * are not running. */
269
+ export function registerAndResolveNetworks(
270
+ state: MachineState,
271
+ ctx: { platform: PlatformInfo; qemuVersion?: string },
272
+ hostfwd: string,
273
+ ): NetworkConfig[] {
274
+ try {
275
+ // Inside the try, not before it: this claims one endpoint per named socket, so a
276
+ // machine on two links whose second link is full would otherwise keep the first
277
+ // claim — and the caller cannot clean it either, since it only learns about the
278
+ // claim once this returns.
279
+ registerSocketMembers(state);
280
+ return resolveAllNetworks(state.networks, { ...ctx, machine: state.name }, hostfwd);
281
+ } catch (e) {
282
+ unregisterSocketMembers(state);
283
+ throw e;
284
+ }
285
+ }
286
+
202
287
  function unregisterSocketMembers(state: MachineState): void {
203
288
  for (const name of getSocketNamedNetworks(state)) {
204
289
  try {
@@ -412,7 +497,10 @@ function createInstance(state: MachineState): ChrInstance {
412
497
  sshPort: ports.ssh,
413
498
  portBase: state.portBase,
414
499
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
415
- tzspGatewayIp: "10.0.2.2",
500
+ hostGatewayIp: HOST_GATEWAY_IP,
501
+ // Deprecated alias for hostGatewayIp (#26) — same value, removed no earlier
502
+ // than the next minor.
503
+ tzspGatewayIp: HOST_GATEWAY_IP,
416
504
 
417
505
  async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
418
506
  // Use resolved credentials so waitForBoot can validate the response body
@@ -464,6 +552,10 @@ function createInstance(state: MachineState): ChrInstance {
464
552
  if (state.pid && isMachineRunning(state)) {
465
553
  await stopQemu(state.pid);
466
554
  }
555
+ // Same invariant as stop()/remove(): a machine that is not running holds no
556
+ // endpoint. clean() stops QEMU directly rather than through stop(), so without
557
+ // this a cleaned machine keeps a pair link occupied and blocks its removal.
558
+ unregisterSocketMembers(state);
467
559
  // NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
468
560
  // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
469
561
  // disk), not OS state; wiping it forces a full device scan (~480s) on the next
@@ -1226,7 +1318,11 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1226
1318
  );
1227
1319
  const platform = await detectPlatform();
1228
1320
  const hostfwd = buildHostfwdString(state.ports);
1229
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
1321
+ const resolvedNetworks = resolveAllNetworks(
1322
+ state.networks,
1323
+ { platform, machine: state.name, qemuVersion: qemuVersionForArch(platform, state.arch) },
1324
+ hostfwd,
1325
+ );
1230
1326
  return {
1231
1327
  arch: state.arch,
1232
1328
  machineDir: state.machineDir,
@@ -1389,9 +1485,8 @@ export class QuickCHR {
1389
1485
  * `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
1390
1486
  static async add(opts: StartOptions = {}): Promise<MachineState> {
1391
1487
  opts = normalizeStartOptions(opts);
1392
- if (opts.name?.startsWith("-")) {
1393
- throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
1394
- }
1488
+ // add() always creates, so the full name rules apply before any I/O.
1489
+ if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
1395
1490
 
1396
1491
  let version: string;
1397
1492
  if (opts.version) {
@@ -1434,7 +1529,15 @@ export class QuickCHR {
1434
1529
  const existingNames = listMachineNames();
1435
1530
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
1436
1531
  if (existingNames.includes(name)) {
1437
- throw new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
1532
+ // A directory with no readable machine.json is a half-created machine, not a
1533
+ // machine — `list` cannot show it and `start` cannot boot it, so saying
1534
+ // "already exists" sends the user looking for something that is not there (#155).
1535
+ throw isOrphanMachineDir(name)
1536
+ ? new QuickCHRError(
1537
+ "MACHINE_EXISTS",
1538
+ `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.`,
1539
+ )
1540
+ : new QuickCHRError("MACHINE_EXISTS", `Machine "${name}" already exists. Use 'quickchr start ${name}' to start it.`);
1438
1541
  }
1439
1542
 
1440
1543
  const usedBases = getUsedPortBases();
@@ -1463,7 +1566,7 @@ export class QuickCHR {
1463
1566
  arch,
1464
1567
  cpu: opts.cpu ?? 1,
1465
1568
  mem: defaultMem(arch, opts.mem),
1466
- networks: resolveStartNetworks(opts.networks, opts.network),
1569
+ networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
1467
1570
  ports,
1468
1571
  packages: opts.packages ?? [],
1469
1572
  installAllPackages: opts.installAllPackages,
@@ -1483,6 +1586,20 @@ export class QuickCHR {
1483
1586
  };
1484
1587
  saveMachine(state);
1485
1588
  return state;
1589
+ } catch (err) {
1590
+ // A failed create must leave nothing behind: the directory exists from this
1591
+ // call's ensureDir(), and without readable state it is invisible to `list`
1592
+ // and unremovable by `remove` while still blocking re-add (#155).
1593
+ //
1594
+ // The test is "no *readable* machine.json", not "no machine.json" — a
1595
+ // saveMachine() that died mid-write (disk full) leaves a truncated file, and
1596
+ // a file-exists check would take that for a finished machine and strand the
1597
+ // directory it was supposed to clean up. Nothing runs after saveMachine()
1598
+ // but `return state`, so a readable file here means the create succeeded.
1599
+ if (isOrphanMachineDir(name)) {
1600
+ try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
1601
+ }
1602
+ throw err;
1486
1603
  } finally {
1487
1604
  try { unlinkSync(lockPath); } catch { /* ignore */ }
1488
1605
  }
@@ -1499,13 +1616,20 @@ export class QuickCHR {
1499
1616
  */
1500
1617
  static async start(opts: StartOptions = {}): Promise<ChrInstance> {
1501
1618
  opts = normalizeStartOptions(opts);
1502
- // Validate name early (before any I/O) so callers get a fast, clear error
1619
+ // Cheap guard before any I/O: a name that reads as a flag is always a mistake.
1620
+ // The full charset rules are applied below, but only when this call creates the
1621
+ // machine — a machine named under the older, looser rules must still start.
1503
1622
  if (opts.name?.startsWith("-")) {
1504
- throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
1623
+ throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-" (it would be read as a flag)`);
1505
1624
  }
1506
1625
 
1507
1626
  const logger = createLogger(opts.onProgress);
1508
1627
 
1628
+ // Before any download or disk work: a named socket whose two ends are already
1629
+ // taken cannot carry this machine, and #156's rule is that a start that cannot
1630
+ // succeed should not first fetch 43 MB.
1631
+ assertNamedSocketsHaveRoom(opts, logger);
1632
+
1509
1633
  const requestedDeviceMode = opts.deviceMode;
1510
1634
  const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
1511
1635
  for (const warning of resolvedDeviceMode.warnings) {
@@ -1566,6 +1690,8 @@ export class QuickCHR {
1566
1690
  // Resolve name
1567
1691
  const existingNames = listMachineNames();
1568
1692
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
1693
+ // Only a *new* name has to satisfy the current rules — see the guard at the top.
1694
+ if (!existingNames.includes(name)) assertValidResourceName(name, "machine");
1569
1695
 
1570
1696
  // Check if machine already exists
1571
1697
  const existing = loadMachine(name);
@@ -1634,7 +1760,7 @@ export class QuickCHR {
1634
1760
  arch,
1635
1761
  cpu: opts.cpu ?? 1,
1636
1762
  mem: defaultMem(arch, opts.mem),
1637
- networks: resolveStartNetworks(opts.networks, opts.network),
1763
+ networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
1638
1764
  ports,
1639
1765
  packages: opts.packages ?? [],
1640
1766
  deviceMode: requestedDeviceMode,
@@ -1657,9 +1783,16 @@ export class QuickCHR {
1657
1783
  // Acquire a lock to prevent concurrent starts of the same machine
1658
1784
  const machineDir = getMachineDir(name);
1659
1785
  assertSufficientQuickchrStorage(`start CHR ${name}`);
1786
+ const dirCreatedHere = !existsSync(machineDir);
1660
1787
  ensureDir(machineDir);
1661
1788
  const lockPath = join(machineDir, ".start-lock");
1662
1789
  acquireLock(lockPath);
1790
+ // Endpoints are claimed before QEMU is spawned, because resolution needs to know
1791
+ // which end this machine holds. A launch that fails before its state is persisted
1792
+ // has no instance lifecycle to release them later, so a failed start would leave a
1793
+ // machine holding an end of a link it never used — and two of those fill the link.
1794
+ // Cleared once saveMachine() has committed the claim.
1795
+ let unpersistedSocketClaim: MachineState | undefined;
1663
1796
  try {
1664
1797
 
1665
1798
  // Download and prepare image
@@ -1693,7 +1826,7 @@ export class QuickCHR {
1693
1826
  opts.secureLogin === true
1694
1827
  );
1695
1828
 
1696
- const networkConfigs = resolveStartNetworks(opts.networks, opts.network);
1829
+ const networkConfigs = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs());
1697
1830
  if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1698
1831
  throw new QuickCHRError(
1699
1832
  "NETWORK_UNAVAILABLE",
@@ -1732,9 +1865,14 @@ export class QuickCHR {
1732
1865
  const accel = await detectAccel(arch);
1733
1866
  const note = accelNote(arch, accel);
1734
1867
  if (note) logger.warn(note);
1735
- registerSocketMembers(state);
1736
1868
  const hostfwd = buildHostfwdString(state.ports);
1737
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
1869
+ const resolvedNetworks = registerAndResolveNetworks(
1870
+ state,
1871
+ { platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
1872
+ hostfwd,
1873
+ );
1874
+ unpersistedSocketClaim = state;
1875
+ reportSocketTransports(state, logger);
1738
1876
 
1739
1877
  const launchConfig: QemuLaunchConfig = {
1740
1878
  arch,
@@ -1757,9 +1895,15 @@ export class QuickCHR {
1757
1895
 
1758
1896
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1759
1897
  if (!background && !hasProvisioning) {
1898
+ // QEMU has already exited here — spawnQemu() blocks in foreground. A machine
1899
+ // that is not running must not keep holding an end of a link: stop() and
1900
+ // remove() unregister, and this path reaches "stopped" without going through
1901
+ // either of them.
1902
+ unregisterSocketMembers(state);
1760
1903
  state.status = "stopped";
1761
1904
  state.lastStartedAt = new Date().toISOString();
1762
1905
  saveMachine(state);
1906
+ unpersistedSocketClaim = undefined;
1763
1907
  return createInstance(state);
1764
1908
  }
1765
1909
 
@@ -1767,6 +1911,7 @@ export class QuickCHR {
1767
1911
  state.lastStartedAt = new Date().toISOString();
1768
1912
 
1769
1913
  saveMachine(state);
1914
+ unpersistedSocketClaim = undefined;
1770
1915
 
1771
1916
  const instance = createInstance(state);
1772
1917
 
@@ -1869,9 +2014,18 @@ export class QuickCHR {
1869
2014
 
1870
2015
  return instance;
1871
2016
  } 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)) {
2017
+ // Release an endpoint this launch claimed but never committed. stop()/remove()
2018
+ // would do it for a machine that reached persisted state; one that did not has
2019
+ // no instance to do it.
2020
+ if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2021
+ // Clean up an orphaned machine directory if the spawn failed before readable
2022
+ // state was saved — same predicate as add()'s cleanup, so a truncated
2023
+ // machine.json is not mistaken for a finished machine.
2024
+ //
2025
+ // `dirCreatedHere` is the difference from add(): start() also starts machines
2026
+ // that already exist, and a directory this call did not create is never ours
2027
+ // to delete, whatever state it is in.
2028
+ if (dirCreatedHere && isOrphanMachineDir(name)) {
1875
2029
  try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
1876
2030
  }
1877
2031
  throw err;
@@ -2026,6 +2180,9 @@ export class QuickCHR {
2026
2180
  ): Promise<ChrInstance> {
2027
2181
  const lockPath = join(state.machineDir, ".start-lock");
2028
2182
  acquireLock(lockPath);
2183
+ // Same pre-persistence window as start(): a relaunch that claims an endpoint and
2184
+ // then fails to spawn would keep it, with no instance to release it.
2185
+ let unpersistedSocketClaim: MachineState | undefined;
2029
2186
  try {
2030
2187
 
2031
2188
  const diskPath = join(state.machineDir, "disk.img");
@@ -2060,9 +2217,14 @@ export class QuickCHR {
2060
2217
  const accel = await detectAccel(state.arch);
2061
2218
  const note = accelNote(state.arch, accel);
2062
2219
  if (note) (logger ?? createLogger()).warn(note);
2063
- registerSocketMembers(state);
2064
2220
  const hostfwd = buildHostfwdString(state.ports);
2065
- const resolvedNetworks = resolveAllNetworks(state.networks, { platform }, hostfwd);
2221
+ const resolvedNetworks = registerAndResolveNetworks(
2222
+ state,
2223
+ { platform, qemuVersion: qemuVersionForArch(platform, state.arch) },
2224
+ hostfwd,
2225
+ );
2226
+ unpersistedSocketClaim = state;
2227
+ reportSocketTransports(state, logger ?? createLogger());
2066
2228
 
2067
2229
  const launchConfig: QemuLaunchConfig = {
2068
2230
  arch: state.arch,
@@ -2085,9 +2247,12 @@ export class QuickCHR {
2085
2247
 
2086
2248
  // Foreground without provisioning: spawnQemu blocks until QEMU exits
2087
2249
  if (!background && !hasProvisioning) {
2250
+ // See the same branch in start(): QEMU has exited, so the endpoint goes back.
2251
+ unregisterSocketMembers(state);
2088
2252
  state.status = "stopped";
2089
2253
  state.lastStartedAt = new Date().toISOString();
2090
2254
  saveMachine(state);
2255
+ unpersistedSocketClaim = undefined;
2091
2256
  return createInstance(state);
2092
2257
  }
2093
2258
 
@@ -2095,6 +2260,7 @@ export class QuickCHR {
2095
2260
  state.status = "running";
2096
2261
  state.lastStartedAt = new Date().toISOString();
2097
2262
  saveMachine(state);
2263
+ unpersistedSocketClaim = undefined;
2098
2264
 
2099
2265
  const instance = createInstance(state);
2100
2266
 
@@ -2160,6 +2326,9 @@ export class QuickCHR {
2160
2326
  } catch { /* never propagate */ }
2161
2327
 
2162
2328
  return instance;
2329
+ } catch (err) {
2330
+ if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2331
+ throw err;
2163
2332
  } finally {
2164
2333
  try { unlinkSync(lockPath); } catch { /* ignore */ }
2165
2334
  }
@@ -2185,6 +2354,54 @@ export class QuickCHR {
2185
2354
  return createInstance(state);
2186
2355
  }
2187
2356
 
2357
+ /** Machines whose `machine.json` is present but cannot be turned into state, each
2358
+ * with the reason: the file would not read, the JSON is invalid, or it parsed into
2359
+ * something that is not a machine.
2360
+ *
2361
+ * `list()` skips these so one corrupt file cannot abort the enumeration; this is
2362
+ * where they stay visible. `get(name)` still throws for such a machine — a lookup
2363
+ * by name is not an enumeration (#165). */
2364
+ static listUnreadable(): Array<{ name: string; error: string }> {
2365
+ return listUnreadableMachines();
2366
+ }
2367
+
2368
+ /** Names of machine directories with no readable `machine.json` — half-created
2369
+ * machines that `list()` cannot show and `get()` cannot resolve (#155). */
2370
+ static listOrphans(): string[] {
2371
+ return listOrphanMachineDirs();
2372
+ }
2373
+
2374
+ /** Delete a half-created machine directory. Returns false when `name` is a real
2375
+ * machine (use `get(name)?.remove()`) or does not exist at all.
2376
+ *
2377
+ * A crash or SIGKILL can strand a directory even with add()'s cleanup in place, and
2378
+ * a stranded directory blocks re-add while being invisible to `list` — so clearing
2379
+ * one must not require knowing that the data dir exists. */
2380
+ static removeOrphan(name: string): boolean {
2381
+ // Explicit, before the predicate: this deletes a directory tree, and `join()`
2382
+ // turns ".." into the data dir itself. Say why rather than answering `false`.
2383
+ assertPathSafeName(name, "machine");
2384
+ if (!isOrphanMachineDir(name)) return false;
2385
+
2386
+ // A create still in flight is indistinguishable from an orphan by directory
2387
+ // contents alone — add()/start() write machine.json last, so the window between
2388
+ // ensureDir() and saveMachine() looks exactly like a half-made machine. The
2389
+ // start-lock is what tells them apart, so take it: acquireLock() throws
2390
+ // MACHINE_LOCKED while a live creator holds it, and replaces one whose owner is
2391
+ // gone — which is the case this recovery exists for.
2392
+ const lockPath = join(getMachineDir(name), ".start-lock");
2393
+ acquireLock(lockPath);
2394
+
2395
+ // The creator may have finished between the check above and the lock.
2396
+ if (!isOrphanMachineDir(name)) {
2397
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
2398
+ return false;
2399
+ }
2400
+
2401
+ removeState(name);
2402
+ return true;
2403
+ }
2404
+
2188
2405
  /** Run doctor checks for prerequisites. */
2189
2406
  static async doctor(): Promise<DoctorResult> {
2190
2407
  const checks: DoctorResult["checks"] = [];
@@ -2373,29 +2590,20 @@ export class QuickCHR {
2373
2590
  }
2374
2591
 
2375
2592
  // 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
- }
2593
+ if (existsSync(getMachinesDir())) {
2594
+ const orphans = listOrphanMachineDirs();
2387
2595
  if (orphans.length > 0) {
2388
- const removeLines = orphans.map((o) => ` rm -rf ${join(machinesDir, o)}`).join("\n");
2596
+ const removeLines = orphans.map((o) => ` quickchr remove ${o}`).join("\n");
2389
2597
  checks.push({
2390
2598
  label: "Orphaned machine dirs",
2391
2599
  status: "warn",
2392
- detail: `${orphans.length} dir(s) without machine.json: ${orphans.join(", ")}\nRemove manually:\n${removeLines}`,
2600
+ detail: `${orphans.length} dir(s) with no readable machine.json: ${orphans.join(", ")}\nClear them with:\n${removeLines}`,
2393
2601
  });
2394
2602
  } else {
2395
2603
  checks.push({
2396
2604
  label: "Machine state",
2397
2605
  status: "ok",
2398
- detail: `${dirs.length} machine(s), no orphans`,
2606
+ detail: `${listMachineNames().length} machine(s), no orphans`,
2399
2607
  });
2400
2608
  }
2401
2609
  }