@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/DESIGN.md +366 -5
- package/MANUAL.md +199 -14
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +10 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +17 -3
- package/src/lib/images.ts +53 -22
- package/src/lib/names.ts +72 -0
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +39 -12
- package/src/lib/quickchr.ts +248 -40
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +29 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
411
|
-
// QEMU
|
|
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
|
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
1873
|
-
|
|
1874
|
-
|
|
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 =
|
|
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
|
-
|
|
2377
|
-
|
|
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) => `
|
|
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)
|
|
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: `${
|
|
2606
|
+
detail: `${listMachineNames().length} machine(s), no orphans`,
|
|
2399
2607
|
});
|
|
2400
2608
|
}
|
|
2401
2609
|
}
|