@tikoci/quickchr 0.4.6 → 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.
@@ -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,
@@ -56,7 +63,8 @@ import { autoPruneIfOverCap } from "./cache.ts";
56
63
  import { resolveSetting } from "./settings.ts";
57
64
  import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
58
65
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
59
- import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
66
+ import { monitorCommand, serialStreams, qgaCommand, channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
67
+ import { captureBootFailure, newBootProbeStats, type BootFailureReport, type BootProbeStats, type FailureTrigger } from "./diagnostics.ts";
60
68
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
61
69
  import { provision } from "./provision.ts";
62
70
  import { renewLicense, getLicenseInfo } from "./license.ts";
@@ -65,9 +73,9 @@ import { scpPush, scpPull } from "./scp.ts";
65
73
  import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
66
74
  import { restExecute } from "./exec.ts";
67
75
  import { qgaExec } from "./qga.ts";
68
- import { consoleExec } from "./console.ts";
76
+ import { consoleExec, CONSOLE_LOGIN_COST_MS } from "./console.ts";
69
77
  import { restRequest, restGet, restPost } from "./rest.ts";
70
- import { createNamedSocket, getNamedSocket, addSocketMember, removeSocketMember } from "./socket-registry.ts";
78
+ import { getNamedSocket, joinNamedSocket, removeSocketMember, getSocketSlot } from "./socket-registry.ts";
71
79
  import { createLogger, type ProgressLogger } from "./log.ts";
72
80
  import {
73
81
  formatDeviceModeSelection,
@@ -79,9 +87,10 @@ import {
79
87
  waitForDeviceModeApi,
80
88
  } from "./device-mode.ts";
81
89
  import type { LicenseOptions } from "./types.ts";
90
+ import type { GuestExec } from "./guest-snapshot.ts";
82
91
  import { toChrPorts } from "./network.ts";
83
92
  import { assertSufficientQuickchrStorage, formatQuickchrUsage, getQuickchrStorageReport } from "./storage.ts";
84
- 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";
85
94
  import { join } from "node:path";
86
95
 
87
96
  // --- Architecture-aware defaults ---
@@ -184,19 +193,97 @@ function getSocketNamedNetworks(state: MachineState): string[] {
184
193
  .map((n) => (n.specifier as { type: "socket"; name: string }).name);
185
194
  }
186
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
+
187
230
  function registerSocketMembers(state: MachineState): void {
188
231
  for (const name of getSocketNamedNetworks(state)) {
189
232
  try {
190
- if (!getNamedSocket(name)) {
191
- createNamedSocket(name, { autoCreated: true });
192
- }
193
- 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 });
194
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;
195
242
  console.warn(`Warning: failed to register socket member "${state.name}" on "${name}": ${e instanceof Error ? e.message : String(e)}`);
196
243
  }
197
244
  }
198
245
  }
199
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
+
200
287
  function unregisterSocketMembers(state: MachineState): void {
201
288
  for (const name of getSocketNamedNetworks(state)) {
202
289
  try {
@@ -410,13 +497,16 @@ function createInstance(state: MachineState): ChrInstance {
410
497
  sshPort: ports.ssh,
411
498
  portBase: state.portBase,
412
499
  captureInterface: process.platform === "darwin" ? "lo0" : "any",
413
- 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,
414
504
 
415
- async waitForBoot(timeoutMs?: number): Promise<boolean> {
505
+ async waitForBoot(timeoutMs?: number, stats?: BootProbeStats): Promise<boolean> {
416
506
  // Use resolved credentials so waitForBoot can validate the response body
417
507
  // on authenticated machines (post-provisioning, post-install reboots).
418
508
  const auth = resolveAuth(state);
419
- return waitForBoot(ports.http, timeoutMs, auth.header);
509
+ return waitForBoot(ports.http, timeoutMs, auth.header, stats);
420
510
  },
421
511
 
422
512
  async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
@@ -462,6 +552,10 @@ function createInstance(state: MachineState): ChrInstance {
462
552
  if (state.pid && isMachineRunning(state)) {
463
553
  await stopQemu(state.pid);
464
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);
465
559
  // NOTE: clean() deliberately does NOT delete efi-vars.fd. On the arm64 `virt`
466
560
  // machine that file stores UEFI boot order (Boot0000 -> first virtio-blk-pci
467
561
  // disk), not OS state; wiping it forces a full device scan (~480s) on the next
@@ -487,18 +581,43 @@ function createInstance(state: MachineState): ChrInstance {
487
581
  state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw"),
488
582
  );
489
583
 
490
- // Clean up stored instance credentials (re-provisioned on next start)
584
+ // Every guest-side credential went with the old disk, so the credential
585
+ // facts in machine.json now describe a machine that no longer exists.
586
+ // Nothing re-provisions them either: a post-clean() start() takes the
587
+ // `_launchExisting(…, undefined)` path (lastStartedAt is set), so the
588
+ // erased account is never recreated. Left in place, `resolveAuth()` /
589
+ // `resolveCreds()` prefer `state.user` and authenticate every REST call,
590
+ // exec, and SCP as a deleted user — the confound #79 removes here.
591
+ // Clearing them restores the documented fallback: factory `admin` with an
592
+ // empty password, which is what a fresh CHR image actually answers to
593
+ // (anchored by the "clean() resets disk to factory defaults" integration
594
+ // test). `disableAdmin` goes with them because it is read as a live fact
595
+ // about the guest (buildDescriptor's `disableAdminLockout`) and a fresh
596
+ // image has admin enabled again. Provisioning *intent* — packages,
597
+ // deviceMode, secureLogin — is not guest state and survives.
491
598
  deleteInstanceCredentials(state.name);
599
+ // The managed keypair authenticated to the account factory reset erased,
600
+ // so it is dead credential material sitting in the machine dir. Removing
601
+ // it also keeps a future re-provision viable: `ssh-keygen -f <path>` on an
602
+ // existing file exits 1 with "already exists" rather than overwriting
603
+ // (verified locally), so a leftover key would break `installSshKey()`.
604
+ rmSync(join(state.machineDir, "ssh"), { recursive: true, force: true });
492
605
 
493
606
  // Update state
494
607
  const current = loadMachine(state.name);
495
608
  if (current) {
496
609
  current.status = "stopped";
497
610
  current.pid = undefined;
611
+ current.user = undefined;
612
+ current.managedSshKey = undefined;
613
+ current.disableAdmin = undefined;
498
614
  saveMachine(current);
499
615
  }
500
616
  state.status = "stopped";
501
617
  state.pid = undefined;
618
+ state.user = undefined;
619
+ state.managedSshKey = undefined;
620
+ state.disableAdmin = undefined;
502
621
  },
503
622
 
504
623
  async monitor(command: string): Promise<string> {
@@ -950,6 +1069,183 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
950
1069
  return false;
951
1070
  }
952
1071
 
1072
+ /** Where boot-failure reports land: `<dataDir>/failures/`, deliberately outside
1073
+ * any machine directory so `remove()` — which every integration test calls from
1074
+ * its `finally` — cannot delete the evidence it was written to explain (#79). */
1075
+ function bootFailureReportDir(): string {
1076
+ return join(getDataDir(), "failures");
1077
+ }
1078
+
1079
+ /**
1080
+ * How long one credential candidate gets inside a single guest-exec call.
1081
+ *
1082
+ * `timeoutMs` is a budget for the whole call, not per attempt: `captureGuestSnapshot()`
1083
+ * sizes its own budget assuming one exec costs at most what it passed in, so N
1084
+ * candidates × the full timeout would spend the snapshot's entire budget on the
1085
+ * first query. Untried candidates therefore share it, and once a credential is
1086
+ * known to work it gets all of it — which is every query after the first.
1087
+ *
1088
+ * The floor is {@link CONSOLE_LOGIN_COST_MS} rather than a round number, because
1089
+ * a share below the cost of a login cannot succeed **by construction** — and a
1090
+ * candidate that can never succeed also never becomes the working one, so every
1091
+ * later query repeats the same doomed division. The floor used to be 5 s against
1092
+ * a measured 11.4 s login, which is why forensics reported "console unreachable"
1093
+ * for guests that were answering serial in 10 ms (#69). A share this large is
1094
+ * affordable only because `captureGuestSnapshot()` adds a login allowance to its
1095
+ * query budget until the console answers; see `GUEST_LOGIN_ALLOWANCE_MS`.
1096
+ *
1097
+ * Exported for tests: this is the arithmetic the #69/B10 fix turns on, and it is
1098
+ * otherwise reachable only through a live serial socket.
1099
+ */
1100
+ export function credentialShareMs(timeoutMs: number, candidateCount: number, haveWorking: boolean): number {
1101
+ if (haveWorking) return timeoutMs;
1102
+ return Math.max(CONSOLE_LOGIN_COST_MS, Math.floor(timeoutMs / Math.max(1, candidateCount)));
1103
+ }
1104
+
1105
+ /** Serial-console executor for boot forensics, or null when there is no serial
1106
+ * channel to talk to (foreground runs, or QEMU never got far enough).
1107
+ *
1108
+ * Which account works depends on how far the failed boot got: a machine that
1109
+ * timed out before provisioning — and any machine after `clean()` — is factory
1110
+ * fresh and answers only to `admin` with an empty password, while a relaunch of
1111
+ * an already-provisioned machine needs its stored credentials. Rather than
1112
+ * guess, candidates are tried in order and the first that reaches a prompt is
1113
+ * reused for the rest of the snapshot. */
1114
+ function bootFailureGuestExec(state: MachineState): { exec: GuestExec; users: string[] } | null {
1115
+ if (!channelFileExists(channelPath(state.machineDir, "serial"))) return null;
1116
+
1117
+ // Order matters only for speed — a wrong candidate costs a ~15 s login timeout
1118
+ // before the next is tried. Stored credentials are the strongest evidence the
1119
+ // machine is provisioned (provisioning stores them on success), so they lead;
1120
+ // factory admin comes next because a cleaned machine has neither (`clean()`
1121
+ // clears both the stored credentials and `state.user`), leaving `state.user`
1122
+ // as the weakest case: a user configured on the machine but never provisioned
1123
+ // onto the guest.
1124
+ const candidates: { user: string; password: string }[] = [];
1125
+ // Guarded even though secrets.ts already swallows unreadable/corrupt stores:
1126
+ // this runs on the BOOT_TIMEOUT path *before* captureBootFailure(), so
1127
+ // anything thrown here would replace the forensics report with an unrelated
1128
+ // error — the exact failure mode the diagnostics modules exist to avoid.
1129
+ let stored: { user: string; password: string } | null = null;
1130
+ try {
1131
+ stored = getInstanceCredentials(state.name);
1132
+ } catch { /* no stored credentials — fall through to the other candidates */ }
1133
+ if (stored) candidates.push(stored);
1134
+ candidates.push({ user: "admin", password: "" });
1135
+ if (state.user && state.user.name !== "admin" && state.user.password !== STORED_IN_SECRETS_PASSWORD) {
1136
+ candidates.push({ user: state.user.name, password: state.user.password });
1137
+ }
1138
+
1139
+ let working: { user: string; password: string } | undefined;
1140
+ const exec: GuestExec = async (command, timeoutMs) => {
1141
+ const order = working ? [working, ...candidates.filter((c) => c !== working)] : candidates;
1142
+ const deadline = Date.now() + timeoutMs;
1143
+ const share = credentialShareMs(timeoutMs, order.length, working !== undefined);
1144
+ let lastError: unknown = new Error("no credential candidates");
1145
+ for (const cred of order) {
1146
+ const remaining = deadline - Date.now();
1147
+ if (remaining <= 0) break;
1148
+ try {
1149
+ const { output, framed } = await consoleExec(
1150
+ state.machineDir, command, cred.user, cred.password, Math.min(share, remaining), state.portBase,
1151
+ );
1152
+ working = cred;
1153
+ return { output, framed };
1154
+ } catch (e) {
1155
+ lastError = e;
1156
+ }
1157
+ }
1158
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1159
+ };
1160
+
1161
+ return { exec, users: candidates.map((c) => c.user) };
1162
+ }
1163
+
1164
+ /**
1165
+ * Capture the boot-failure evidence set for a machine that is **already
1166
+ * running** — a request that failed after the machine was declared REST-ready.
1167
+ *
1168
+ * Same instruments as the BOOT_TIMEOUT path (guest snapshot, per-port slirp
1169
+ * classification, monitor state, QEMU liveness, embedded logs), because #69's
1170
+ * one-line `ECONNRESET` has never been enough to say whether the reset came from
1171
+ * the client, RouterOS `www`, or slirp — and every one of those instruments
1172
+ * answers a different part of that. What it does *not* share is the boot
1173
+ * budget's excuse for the machine looking healthy: {@link FailureTrigger} is
1174
+ * required here, so the report always records which request died and what
1175
+ * changed just before it.
1176
+ *
1177
+ * Does not stop, remove, or otherwise touch the machine — the caller owns its
1178
+ * lifecycle and may well want to keep testing against it. Never throws;
1179
+ * `captureBootFailure()` swallows its own failures.
1180
+ *
1181
+ * One trace it does leave, and the reason that matters more here than on the
1182
+ * boot path: the guest snapshot logs in over the serial console, and a RouterOS
1183
+ * console login survives the host socket closing. A boot-timeout capture is
1184
+ * followed by `stop()`/`remove()`, so nothing outlives it; here the machine
1185
+ * keeps running. Harmless — `consoleExec()` `/quit`s a session belonging to a
1186
+ * different user before it logs in — but a later console interaction on this
1187
+ * machine starts from an already-logged-in prompt, not a `Login:` one.
1188
+ */
1189
+ export async function captureRunningFailure(
1190
+ state: MachineState,
1191
+ phase: string,
1192
+ trigger: FailureTrigger,
1193
+ ): Promise<BootFailureReport> {
1194
+ // Approximate, and labelled as such in the field docs: lastStartedAt is
1195
+ // stamped at spawn and lastBootMs measured from just before it, so their sum
1196
+ // is REST-ready to within the few ms between those two statements. Both are
1197
+ // absent on a machine that never completed a timed boot, in which case the
1198
+ // field is simply omitted rather than guessed at.
1199
+ const readyAt = state.lastStartedAt !== undefined && state.lastBootMs !== undefined
1200
+ ? Date.parse(state.lastStartedAt) + state.lastBootMs
1201
+ : undefined;
1202
+ const guest = bootFailureGuestExec(state);
1203
+ return await captureBootFailure({
1204
+ name: state.name,
1205
+ machineDir: state.machineDir,
1206
+ arch: state.arch,
1207
+ accel: state.lastAccel ?? "unknown",
1208
+ pid: state.pid,
1209
+ httpPort: state.ports.http?.host,
1210
+ portBase: state.portBase,
1211
+ phase,
1212
+ trigger: {
1213
+ ...trigger,
1214
+ sinceReadyMs: trigger.sinceReadyMs
1215
+ ?? (readyAt !== undefined && Number.isFinite(readyAt) ? Date.now() - readyAt : undefined),
1216
+ },
1217
+ reportDir: bootFailureReportDir(),
1218
+ monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
1219
+ forwards: Object.values(state.ports),
1220
+ guestExec: guest?.exec,
1221
+ guestUser: guest?.users.join(" → "),
1222
+ });
1223
+ }
1224
+
1225
+ /** Tear down a machine that failed to boot, and describe what was done.
1226
+ *
1227
+ * Default is full cleanup — a failed `start()` must not leave a half-dead
1228
+ * machine behind for the next call to trip over. QUICKCHR_PRESERVE_ON_FAILURE=1
1229
+ * inverts that for **local** debugging: the QEMU process is still stopped (it is
1230
+ * wedged, and its port block must be released), but the machine directory
1231
+ * survives so it can be inspected by hand.
1232
+ *
1233
+ * Evidence does not depend on that flag, and CI deliberately does not set it —
1234
+ * `captureBootFailure()` has already written its report outside the machine dir
1235
+ * with qemu.log and serial.log embedded, so cleanup cannot destroy it (#79). */
1236
+ async function cleanupAfterBootFailure(
1237
+ instance: ChrInstance,
1238
+ name: string,
1239
+ failure: BootFailureReport,
1240
+ ): Promise<string> {
1241
+ try { await instance.stop(); } catch { /* ignore */ }
1242
+ if (failure.preserved) {
1243
+ return ` Machine "${name}" was preserved for diagnosis (QUICKCHR_PRESERVE_ON_FAILURE=1) — remove it with \`quickchr remove ${name}\`.`;
1244
+ }
1245
+ try { await instance.remove(); } catch { /* ignore */ }
1246
+ return ` Machine "${name}" has been cleaned up automatically.`;
1247
+ }
1248
+
953
1249
  /** Wait for CHR to boot with periodic progress status updates every 20s. */
954
1250
  async function waitForBootWithProgress(
955
1251
  instance: ChrInstance,
@@ -1022,7 +1318,11 @@ async function buildLaunchConfigFromState(state: MachineState): Promise<QemuLaun
1022
1318
  );
1023
1319
  const platform = await detectPlatform();
1024
1320
  const hostfwd = buildHostfwdString(state.ports);
1025
- 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
+ );
1026
1326
  return {
1027
1327
  arch: state.arch,
1028
1328
  machineDir: state.machineDir,
@@ -1185,9 +1485,8 @@ export class QuickCHR {
1185
1485
  * `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
1186
1486
  static async add(opts: StartOptions = {}): Promise<MachineState> {
1187
1487
  opts = normalizeStartOptions(opts);
1188
- if (opts.name?.startsWith("-")) {
1189
- throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
1190
- }
1488
+ // add() always creates, so the full name rules apply before any I/O.
1489
+ if (opts.name !== undefined) assertValidResourceName(opts.name, "machine");
1191
1490
 
1192
1491
  let version: string;
1193
1492
  if (opts.version) {
@@ -1230,7 +1529,15 @@ export class QuickCHR {
1230
1529
  const existingNames = listMachineNames();
1231
1530
  const name = opts.name ?? generateMachineName(version, arch, existingNames);
1232
1531
  if (existingNames.includes(name)) {
1233
- 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.`);
1234
1541
  }
1235
1542
 
1236
1543
  const usedBases = getUsedPortBases();
@@ -1259,7 +1566,7 @@ export class QuickCHR {
1259
1566
  arch,
1260
1567
  cpu: opts.cpu ?? 1,
1261
1568
  mem: defaultMem(arch, opts.mem),
1262
- networks: resolveStartNetworks(opts.networks, opts.network),
1569
+ networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
1263
1570
  ports,
1264
1571
  packages: opts.packages ?? [],
1265
1572
  installAllPackages: opts.installAllPackages,
@@ -1279,6 +1586,20 @@ export class QuickCHR {
1279
1586
  };
1280
1587
  saveMachine(state);
1281
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;
1282
1603
  } finally {
1283
1604
  try { unlinkSync(lockPath); } catch { /* ignore */ }
1284
1605
  }
@@ -1295,13 +1616,20 @@ export class QuickCHR {
1295
1616
  */
1296
1617
  static async start(opts: StartOptions = {}): Promise<ChrInstance> {
1297
1618
  opts = normalizeStartOptions(opts);
1298
- // 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.
1299
1622
  if (opts.name?.startsWith("-")) {
1300
- 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)`);
1301
1624
  }
1302
1625
 
1303
1626
  const logger = createLogger(opts.onProgress);
1304
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
+
1305
1633
  const requestedDeviceMode = opts.deviceMode;
1306
1634
  const resolvedDeviceMode = resolveDeviceModeOptions(requestedDeviceMode);
1307
1635
  for (const warning of resolvedDeviceMode.warnings) {
@@ -1362,6 +1690,8 @@ export class QuickCHR {
1362
1690
  // Resolve name
1363
1691
  const existingNames = listMachineNames();
1364
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");
1365
1695
 
1366
1696
  // Check if machine already exists
1367
1697
  const existing = loadMachine(name);
@@ -1430,7 +1760,7 @@ export class QuickCHR {
1430
1760
  arch,
1431
1761
  cpu: opts.cpu ?? 1,
1432
1762
  mem: defaultMem(arch, opts.mem),
1433
- networks: resolveStartNetworks(opts.networks, opts.network),
1763
+ networks: assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs()),
1434
1764
  ports,
1435
1765
  packages: opts.packages ?? [],
1436
1766
  deviceMode: requestedDeviceMode,
@@ -1453,9 +1783,16 @@ export class QuickCHR {
1453
1783
  // Acquire a lock to prevent concurrent starts of the same machine
1454
1784
  const machineDir = getMachineDir(name);
1455
1785
  assertSufficientQuickchrStorage(`start CHR ${name}`);
1786
+ const dirCreatedHere = !existsSync(machineDir);
1456
1787
  ensureDir(machineDir);
1457
1788
  const lockPath = join(machineDir, ".start-lock");
1458
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;
1459
1796
  try {
1460
1797
 
1461
1798
  // Download and prepare image
@@ -1489,7 +1826,7 @@ export class QuickCHR {
1489
1826
  opts.secureLogin === true
1490
1827
  );
1491
1828
 
1492
- const networkConfigs = resolveStartNetworks(opts.networks, opts.network);
1829
+ const networkConfigs = assignMacs(name, resolveStartNetworks(opts.networks, opts.network), getUsedMacs());
1493
1830
  if (hasProvisioning && !hasUserModeNetwork(networkConfigs)) {
1494
1831
  throw new QuickCHRError(
1495
1832
  "NETWORK_UNAVAILABLE",
@@ -1528,9 +1865,14 @@ export class QuickCHR {
1528
1865
  const accel = await detectAccel(arch);
1529
1866
  const note = accelNote(arch, accel);
1530
1867
  if (note) logger.warn(note);
1531
- registerSocketMembers(state);
1532
1868
  const hostfwd = buildHostfwdString(state.ports);
1533
- 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);
1534
1876
 
1535
1877
  const launchConfig: QemuLaunchConfig = {
1536
1878
  arch,
@@ -1553,9 +1895,15 @@ export class QuickCHR {
1553
1895
 
1554
1896
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1555
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);
1556
1903
  state.status = "stopped";
1557
1904
  state.lastStartedAt = new Date().toISOString();
1558
1905
  saveMachine(state);
1906
+ unpersistedSocketClaim = undefined;
1559
1907
  return createInstance(state);
1560
1908
  }
1561
1909
 
@@ -1563,13 +1911,15 @@ export class QuickCHR {
1563
1911
  state.lastStartedAt = new Date().toISOString();
1564
1912
 
1565
1913
  saveMachine(state);
1914
+ unpersistedSocketClaim = undefined;
1566
1915
 
1567
1916
  const instance = createInstance(state);
1568
1917
 
1569
1918
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1570
1919
 
1571
1920
  // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1572
- let booted = await instance.waitForBoot(bootTimeout);
1921
+ const probe = newBootProbeStats();
1922
+ let booted = await instance.waitForBoot(bootTimeout, probe);
1573
1923
 
1574
1924
  // Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
1575
1925
  // runners) occasionally produce a single QEMU process that boots but never
@@ -1586,22 +1936,35 @@ export class QuickCHR {
1586
1936
  ({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
1587
1937
  state.pid = pid;
1588
1938
  saveMachine(state);
1589
- booted = await instance.waitForBoot(bootTimeout);
1939
+ booted = await instance.waitForBoot(bootTimeout, probe);
1590
1940
  }
1591
1941
 
1592
1942
  if (!booted) {
1593
- let qemuLogTail = "";
1594
- try {
1595
- const logPath = join(machineDir, "qemu.log");
1596
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1597
- } catch { /* ignore */ }
1598
- try { await instance.stop(); } catch { /* ignore */ }
1599
- try { await instance.remove(); } catch { /* ignore */ }
1943
+ const guest = bootFailureGuestExec(state);
1944
+ const failure = await captureBootFailure({
1945
+ name,
1946
+ machineDir,
1947
+ arch,
1948
+ accel,
1949
+ bootTimeoutMs: bootTimeout,
1950
+ pid,
1951
+ httpPort: instance.ports.http,
1952
+ portBase: state.portBase,
1953
+ qemuArgs,
1954
+ probe,
1955
+ phase: "start",
1956
+ reportDir: bootFailureReportDir(),
1957
+ monitorQuery: (cmd) => monitorCommand(machineDir, cmd, 3000, state.portBase),
1958
+ forwards: Object.values(state.ports),
1959
+ guestExec: guest?.exec,
1960
+ guestUser: guest?.users.join(" → "),
1961
+ });
1600
1962
  throw new QuickCHRError(
1601
1963
  "BOOT_TIMEOUT",
1602
1964
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1603
1965
  (hasProvisioning ? " — provisioning could not run." : ".") +
1604
- ` Machine "${name}" has been cleaned up automatically.${qemuLogTail}`,
1966
+ await cleanupAfterBootFailure(instance, name, failure) +
1967
+ `\n${failure.summary}`,
1605
1968
  );
1606
1969
  }
1607
1970
 
@@ -1651,9 +2014,18 @@ export class QuickCHR {
1651
2014
 
1652
2015
  return instance;
1653
2016
  } catch (err) {
1654
- // Clean up orphaned machine directory if spawn failed before machine.json was saved
1655
- const machineJsonPath = join(machineDir, "machine.json");
1656
- 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)) {
1657
2029
  try { rmSync(machineDir, { recursive: true, force: true }); } catch { /* best effort */ }
1658
2030
  }
1659
2031
  throw err;
@@ -1808,6 +2180,9 @@ export class QuickCHR {
1808
2180
  ): Promise<ChrInstance> {
1809
2181
  const lockPath = join(state.machineDir, ".start-lock");
1810
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;
1811
2186
  try {
1812
2187
 
1813
2188
  const diskPath = join(state.machineDir, "disk.img");
@@ -1842,9 +2217,14 @@ export class QuickCHR {
1842
2217
  const accel = await detectAccel(state.arch);
1843
2218
  const note = accelNote(state.arch, accel);
1844
2219
  if (note) (logger ?? createLogger()).warn(note);
1845
- registerSocketMembers(state);
1846
2220
  const hostfwd = buildHostfwdString(state.ports);
1847
- 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());
1848
2228
 
1849
2229
  const launchConfig: QemuLaunchConfig = {
1850
2230
  arch: state.arch,
@@ -1867,9 +2247,12 @@ export class QuickCHR {
1867
2247
 
1868
2248
  // Foreground without provisioning: spawnQemu blocks until QEMU exits
1869
2249
  if (!background && !hasProvisioning) {
2250
+ // See the same branch in start(): QEMU has exited, so the endpoint goes back.
2251
+ unregisterSocketMembers(state);
1870
2252
  state.status = "stopped";
1871
2253
  state.lastStartedAt = new Date().toISOString();
1872
2254
  saveMachine(state);
2255
+ unpersistedSocketClaim = undefined;
1873
2256
  return createInstance(state);
1874
2257
  }
1875
2258
 
@@ -1877,25 +2260,40 @@ export class QuickCHR {
1877
2260
  state.status = "running";
1878
2261
  state.lastStartedAt = new Date().toISOString();
1879
2262
  saveMachine(state);
2263
+ unpersistedSocketClaim = undefined;
1880
2264
 
1881
2265
  const instance = createInstance(state);
1882
2266
 
1883
2267
  // Always wait for boot in background mode — start() promises "REST-ready".
1884
2268
  const bootTimeout = defaultBootTimeout(state.arch, provisioningOpts?.installAllPackages, accel);
1885
- const booted = await instance.waitForBoot(bootTimeout);
2269
+ const probe = newBootProbeStats();
2270
+ const booted = await instance.waitForBoot(bootTimeout, probe);
1886
2271
  if (!booted) {
1887
- let qemuLogTail = "";
1888
- try {
1889
- const logPath = join(state.machineDir, "qemu.log");
1890
- if (existsSync(logPath)) qemuLogTail = `\nqemu.log:\n${readFileSync(logPath, "utf-8").slice(-1200)}`;
1891
- } catch { /* ignore */ }
1892
- try { await instance.stop(); } catch { /* ignore */ }
1893
- try { await instance.remove(); } catch { /* ignore */ }
2272
+ const guest = bootFailureGuestExec(state);
2273
+ const failure = await captureBootFailure({
2274
+ name: state.name,
2275
+ machineDir: state.machineDir,
2276
+ arch: state.arch,
2277
+ accel,
2278
+ bootTimeoutMs: bootTimeout,
2279
+ pid,
2280
+ httpPort: instance.ports.http,
2281
+ portBase: state.portBase,
2282
+ qemuArgs,
2283
+ probe,
2284
+ phase: "_launchExisting",
2285
+ reportDir: bootFailureReportDir(),
2286
+ monitorQuery: (cmd) => monitorCommand(state.machineDir, cmd, 3000, state.portBase),
2287
+ forwards: Object.values(state.ports),
2288
+ guestExec: guest?.exec,
2289
+ guestUser: guest?.users.join(" → "),
2290
+ });
1894
2291
  throw new QuickCHRError(
1895
2292
  "BOOT_TIMEOUT",
1896
2293
  `CHR did not respond within ${bootTimeout / 1000}s (accel=${accel})` +
1897
2294
  (hasProvisioning ? " — provisioning could not run." : ".") +
1898
- ` Machine "${state.name}" has been cleaned up automatically.${qemuLogTail}`,
2295
+ await cleanupAfterBootFailure(instance, state.name, failure) +
2296
+ `\n${failure.summary}`,
1899
2297
  );
1900
2298
  }
1901
2299
 
@@ -1928,6 +2326,9 @@ export class QuickCHR {
1928
2326
  } catch { /* never propagate */ }
1929
2327
 
1930
2328
  return instance;
2329
+ } catch (err) {
2330
+ if (unpersistedSocketClaim) unregisterSocketMembers(unpersistedSocketClaim);
2331
+ throw err;
1931
2332
  } finally {
1932
2333
  try { unlinkSync(lockPath); } catch { /* ignore */ }
1933
2334
  }
@@ -1953,6 +2354,54 @@ export class QuickCHR {
1953
2354
  return createInstance(state);
1954
2355
  }
1955
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
+
1956
2405
  /** Run doctor checks for prerequisites. */
1957
2406
  static async doctor(): Promise<DoctorResult> {
1958
2407
  const checks: DoctorResult["checks"] = [];
@@ -2141,29 +2590,20 @@ export class QuickCHR {
2141
2590
  }
2142
2591
 
2143
2592
  // Orphaned machine directories (have files but no machine.json)
2144
- const machinesDir = getMachinesDir();
2145
- if (existsSync(machinesDir)) {
2146
- const dirs = readdirSync(machinesDir, { withFileTypes: true })
2147
- .filter(e => e.isDirectory());
2148
- const orphans: string[] = [];
2149
- for (const dir of dirs) {
2150
- const mjPath = join(machinesDir, dir.name, "machine.json");
2151
- if (!existsSync(mjPath)) {
2152
- orphans.push(dir.name);
2153
- }
2154
- }
2593
+ if (existsSync(getMachinesDir())) {
2594
+ const orphans = listOrphanMachineDirs();
2155
2595
  if (orphans.length > 0) {
2156
- const removeLines = orphans.map((o) => ` rm -rf ${join(machinesDir, o)}`).join("\n");
2596
+ const removeLines = orphans.map((o) => ` quickchr remove ${o}`).join("\n");
2157
2597
  checks.push({
2158
2598
  label: "Orphaned machine dirs",
2159
2599
  status: "warn",
2160
- 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}`,
2161
2601
  });
2162
2602
  } else {
2163
2603
  checks.push({
2164
2604
  label: "Machine state",
2165
2605
  status: "ok",
2166
- detail: `${dirs.length} machine(s), no orphans`,
2606
+ detail: `${listMachineNames().length} machine(s), no orphans`,
2167
2607
  });
2168
2608
  }
2169
2609
  }