@tikoci/quickchr 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Bounded guest shutdown, independently observing process exit (#143). */
|
|
2
|
+
import { QuickCHRError } from "./types.ts";
|
|
3
|
+
import type { ShutdownResult } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export function validateShutdownTimeout(timeoutMs: number): number {
|
|
8
|
+
// node:http timers cannot represent a larger delay; overflow becomes a 1 ms timer.
|
|
9
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
10
|
+
throw new QuickCHRError("INVALID_ARGUMENT", "shutdown timeoutMs must be a positive integer no greater than 2147483647 milliseconds");
|
|
11
|
+
}
|
|
12
|
+
return timeoutMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The process observer is separate from the request: RouterOS can return HTTP 400
|
|
16
|
+
* `interrupted` while successfully halting. Request must enforce its timeout (exec does). */
|
|
17
|
+
export async function shutdownGuest(
|
|
18
|
+
timeoutMs: number,
|
|
19
|
+
request: (timeoutMs: number) => Promise<unknown>,
|
|
20
|
+
isRunning: () => boolean,
|
|
21
|
+
forceStop: () => Promise<unknown>,
|
|
22
|
+
): Promise<ShutdownResult> {
|
|
23
|
+
const deadline = performance.now() + timeoutMs;
|
|
24
|
+
let requestError: string | undefined;
|
|
25
|
+
const pending = Promise.resolve().then(() => request(Math.max(1, Math.ceil(deadline - performance.now())))).catch((error: unknown) => {
|
|
26
|
+
requestError = error instanceof Error ? error.message : String(error);
|
|
27
|
+
});
|
|
28
|
+
while (isRunning() && performance.now() < deadline) {
|
|
29
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
30
|
+
}
|
|
31
|
+
const guestExited = !isRunning();
|
|
32
|
+
// exec's HTTP timer settles this within the same grace budget, including when
|
|
33
|
+
// QEMU exits while its HTTP response is still pending. Never replay the request.
|
|
34
|
+
await pending;
|
|
35
|
+
if (guestExited || !isRunning()) {
|
|
36
|
+
return { outcome: "guest-exited", ...(requestError === undefined ? {} : { requestError }) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await forceStop();
|
|
41
|
+
} catch {
|
|
42
|
+
if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not terminate QEMU; machine remains running");
|
|
43
|
+
}
|
|
44
|
+
// stopQemu sends SIGKILL after its SIGTERM wait but does not await that exit.
|
|
45
|
+
const killDeadline = performance.now() + 1000;
|
|
46
|
+
while (isRunning() && performance.now() < killDeadline) await Bun.sleep(50);
|
|
47
|
+
if (isRunning()) throw new QuickCHRError("STATE_ERROR", "shutdown could not confirm QEMU exit; machine remains running");
|
|
48
|
+
return { outcome: "forced", ...(requestError === undefined ? {} : { requestError }) };
|
|
49
|
+
}
|
|
@@ -3,19 +3,46 @@
|
|
|
3
3
|
* Socket entries are stored as JSON files in ~/.local/share/quickchr/networks/.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, openSync, closeSync, writeSync, renameSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
9
9
|
import { getDataDir } from "./state.ts";
|
|
10
|
+
import { assertValidResourceName, assertPathSafeName } from "./names.ts";
|
|
11
|
+
|
|
12
|
+
/** Transport a named socket uses to carry guest frames.
|
|
13
|
+
*
|
|
14
|
+
* - `dgram` — a pair of unix datagram sockets (QEMU >= 7.2). No ports, no UDP
|
|
15
|
+
* syscalls, and **either end may start first**. Two members. POSIX only:
|
|
16
|
+
* Windows' AF_UNIX has no SOCK_DGRAM.
|
|
17
|
+
* - `listen-connect` — a TCP pair on loopback. Two members; the first to join takes
|
|
18
|
+
* the listening slot and keeps it, so that machine starts first thereafter.
|
|
19
|
+
* The Windows default.
|
|
20
|
+
* - `mcast` — UDP multicast, the only N-way segment. Broken on macOS and in
|
|
21
|
+
* sandboxes that block UDP, and it fails *silently* in both (see DESIGN.md).
|
|
22
|
+
*/
|
|
23
|
+
export type SocketMode = "mcast" | "listen-connect" | "dgram";
|
|
10
24
|
|
|
11
25
|
export interface SocketEntry {
|
|
12
26
|
name: string;
|
|
13
|
-
mode:
|
|
27
|
+
mode: SocketMode;
|
|
14
28
|
mcastGroup?: string;
|
|
15
|
-
port
|
|
29
|
+
/** Host port for `listen-connect` and `mcast`. Absent on a `dgram` link, which
|
|
30
|
+
* addresses its ends by filesystem path and uses no port at all — carrying a
|
|
31
|
+
* number there would be a field that looks meaningful and is not. */
|
|
32
|
+
port?: number;
|
|
16
33
|
createdAt: string;
|
|
17
34
|
/** Machines that currently reference this socket. */
|
|
18
35
|
members: string[];
|
|
36
|
+
/** Slot occupancy for the two-member modes: index is the endpoint, value is the
|
|
37
|
+
* machine holding it or `null` when free.
|
|
38
|
+
*
|
|
39
|
+
* Slot 0 is the listener (`listen-connect`) or endpoint A (`dgram`). The role has
|
|
40
|
+
* to be *persisted* rather than derived from `members.length`, which is what made
|
|
41
|
+
* `listen-connect` a dead link for its whole life: `registerSocketMembers()` runs
|
|
42
|
+
* before `resolveAllNetworks()`, so the starting machine had always added itself
|
|
43
|
+
* by the time the resolver asked whether it was first, and every member resolved
|
|
44
|
+
* to `connect=`. Nobody ever listened. */
|
|
45
|
+
endpoints?: (string | null)[];
|
|
19
46
|
/** Auto-created by `start()` (true) vs explicit user/CLI creation (false). */
|
|
20
47
|
autoCreated: boolean;
|
|
21
48
|
}
|
|
@@ -23,6 +50,22 @@ export interface SocketEntry {
|
|
|
23
50
|
const DEFAULT_START_PORT = 4000;
|
|
24
51
|
const DEFAULT_MCAST_GROUP = "230.0.0.1";
|
|
25
52
|
|
|
53
|
+
/** `sun_path` is 104 bytes on macOS and 108 on Linux; QEMU rejects a longer path at
|
|
54
|
+
* spawn with `UNIX socket path '...' is too long`. Checked here so the error names
|
|
55
|
+
* the data dir instead of arriving as a QEMU spawn failure. */
|
|
56
|
+
const SUN_PATH_MAX = 104;
|
|
57
|
+
|
|
58
|
+
/** The transport a named socket gets when the caller does not name one.
|
|
59
|
+
*
|
|
60
|
+
* `dgram` everywhere it exists, because it is the only one of the three with no
|
|
61
|
+
* start-ordering constraint, no host port and no UDP syscall. Windows has no
|
|
62
|
+
* AF_UNIX datagram socket, so it falls back to the TCP pair. The resolved value is
|
|
63
|
+
* persisted at creation, never re-derived: a `networks/<name>.json` states the
|
|
64
|
+
* transport it was made with, so one specifier never silently means two things. */
|
|
65
|
+
export function defaultSocketMode(platform: NodeJS.Platform = process.platform): SocketMode {
|
|
66
|
+
return platform === "win32" ? "listen-connect" : "dgram";
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
// In-memory write-through cache — works around Bun Windows FS caching bugs
|
|
27
70
|
// where writeFileSync followed by immediate readdirSync/readFileSync returns stale data.
|
|
28
71
|
// Uses a Map for well-defined semantics on every Bun platform; the previous plain-
|
|
@@ -41,11 +84,16 @@ export function getSocketRegistryDir(): string {
|
|
|
41
84
|
}
|
|
42
85
|
|
|
43
86
|
function socketPath(name: string): string {
|
|
87
|
+
// The single place a name becomes a path, so the traversal guard lives here
|
|
88
|
+
// rather than at each caller: `networks sockets remove ../victim` used to delete
|
|
89
|
+
// <dataDir>/victim.json, and a longer prefix reached outside the data dir entirely.
|
|
90
|
+
// createNamedSocket() applies the stricter creation rules on top of this.
|
|
91
|
+
assertPathSafeName(name, "named socket");
|
|
44
92
|
return join(getSocketRegistryDir(), `${name}.json`);
|
|
45
93
|
}
|
|
46
94
|
|
|
47
95
|
/** Normalize an entry loaded from disk: defaults missing fields for backward compat. */
|
|
48
|
-
function normalizeEntry(raw: Partial<SocketEntry> & { name: string; mode: SocketEntry["mode"];
|
|
96
|
+
function normalizeEntry(raw: Partial<SocketEntry> & { name: string; mode: SocketEntry["mode"]; createdAt: string }): SocketEntry {
|
|
49
97
|
return {
|
|
50
98
|
name: raw.name,
|
|
51
99
|
mode: raw.mode,
|
|
@@ -53,45 +101,272 @@ function normalizeEntry(raw: Partial<SocketEntry> & { name: string; mode: Socket
|
|
|
53
101
|
port: raw.port,
|
|
54
102
|
createdAt: raw.createdAt,
|
|
55
103
|
members: Array.isArray(raw.members) ? [...raw.members] : [],
|
|
104
|
+
// Entries written before slots existed have no `endpoints`. `mcast` never has
|
|
105
|
+
// them; a pair link needs them, or every member resolves to "holds no endpoint"
|
|
106
|
+
// and the link cannot start at all. `listen-connect` was reachable from the
|
|
107
|
+
// library API before the CLI could spell it, so such entries can exist. Seeded
|
|
108
|
+
// empty rather than from `members`, which is live membership and can be stale
|
|
109
|
+
// after a crash — the machines reclaim their ends on the next start.
|
|
110
|
+
endpoints: Array.isArray(raw.endpoints)
|
|
111
|
+
? [...raw.endpoints]
|
|
112
|
+
: raw.mode === "mcast" ? undefined : [null, null],
|
|
56
113
|
autoCreated: typeof raw.autoCreated === "boolean" ? raw.autoCreated : false,
|
|
57
114
|
};
|
|
58
115
|
}
|
|
59
116
|
|
|
117
|
+
/** Write the entry so a concurrent reader never sees a half-written file.
|
|
118
|
+
*
|
|
119
|
+
* `writeFileSync` truncates and then fills, so another process reading in that window
|
|
120
|
+
* gets invalid JSON and `getNamedSocket()` reports the socket as missing. Two starts
|
|
121
|
+
* racing to join one link hit it — observed on 3 of 12 rounds of a two-process
|
|
122
|
+
* repro. Write-then-rename makes the swap atomic. */
|
|
60
123
|
function saveEntry(entry: SocketEntry): void {
|
|
61
|
-
|
|
124
|
+
const target = socketPath(entry.name);
|
|
125
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
126
|
+
writeFileSync(tmp, JSON.stringify(entry, null, "\t") + "\n");
|
|
127
|
+
renameSync(tmp, target);
|
|
62
128
|
_cache.set(entry.name, entry);
|
|
63
129
|
}
|
|
64
130
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
131
|
+
/** How long to wait for another process to finish its read-modify-write. The critical
|
|
132
|
+
* section is one small file write, so this is many orders of magnitude of headroom;
|
|
133
|
+
* it exists so a crashed holder cannot wedge a start forever. */
|
|
134
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
135
|
+
|
|
136
|
+
/** Serialize a read-modify-write of one registry entry across processes.
|
|
137
|
+
*
|
|
138
|
+
* `addSocketMember()` reads the entry, picks a free slot and writes it back. Two
|
|
139
|
+
* machines joining one link concurrently both read `[null, null]`, both take slot 0,
|
|
140
|
+
* and the second write wins — so both QEMUs are handed the same `local.path`, and on
|
|
141
|
+
* a `dgram` link the second silently unlinks the first's socket and steals the link.
|
|
142
|
+
* A two-process repro lost an endpoint on 12 of 12 rounds.
|
|
143
|
+
*
|
|
144
|
+
* `quickchr start a & quickchr start b &` is the shape that hits this, and it is
|
|
145
|
+
* exactly how the field report drove its multi-CHR lab. The per-machine lock
|
|
146
|
+
* cannot help: the two contenders are different machines. */
|
|
147
|
+
const _heldLocks = new Set<string>();
|
|
148
|
+
|
|
149
|
+
/** How long a lock file may be unreadable before it counts as abandoned.
|
|
150
|
+
*
|
|
151
|
+
* A holder creates the file and writes its pid in two syscalls, so there is a window
|
|
152
|
+
* where the file exists and is empty. Deleting on first sight of an empty file takes
|
|
153
|
+
* the lock away from a live holder, and then two processes both believe they hold it
|
|
154
|
+
* — which is how three concurrent joiners all reported success while only two held an
|
|
155
|
+
* endpoint. The window is microseconds; a crashed holder is still reclaimed. */
|
|
156
|
+
const LOCK_STALE_GRACE_MS = 500;
|
|
157
|
+
|
|
158
|
+
function withEntryLock<T>(name: string, fn: () => T): T {
|
|
159
|
+
const lockPath = `${socketPath(name)}.lock`;
|
|
160
|
+
// Reentrant within a process: removeSocketMember() deletes an exhausted
|
|
161
|
+
// auto-created link through removeNamedSocket(), which takes the same lock.
|
|
162
|
+
if (_heldLocks.has(lockPath)) return fn();
|
|
163
|
+
|
|
164
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
165
|
+
let unreadableSince: number | undefined;
|
|
166
|
+
let held = false;
|
|
167
|
+
|
|
168
|
+
while (!held) {
|
|
169
|
+
// Checked here, not only on the live-owner path: a lock another process keeps
|
|
170
|
+
// recreating would otherwise reset the grace timer forever and never time out.
|
|
171
|
+
if (Date.now() > deadline) {
|
|
172
|
+
throw new QuickCHRError(
|
|
173
|
+
"STATE_ERROR",
|
|
174
|
+
`Timed out after ${LOCK_TIMEOUT_MS}ms waiting for the registry lock on named socket "${name}".`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const fd = openSync(lockPath, "wx"); // O_CREAT | O_EXCL — atomic
|
|
179
|
+
writeSync(fd, String(process.pid));
|
|
180
|
+
closeSync(fd);
|
|
181
|
+
held = true;
|
|
182
|
+
_heldLocks.add(lockPath);
|
|
183
|
+
break;
|
|
184
|
+
} catch { /* someone else holds it */ }
|
|
185
|
+
|
|
186
|
+
// A holder that died mid-write would otherwise wedge every later start.
|
|
187
|
+
let ownerPid = Number.NaN;
|
|
188
|
+
try { ownerPid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10); } catch { /* unreadable */ }
|
|
189
|
+
|
|
190
|
+
if (!Number.isFinite(ownerPid) || ownerPid <= 0) {
|
|
191
|
+
// Empty or gone: either the holder has not written its pid yet, or it
|
|
192
|
+
// released between our open and our read. Give it the grace period before
|
|
193
|
+
// concluding anything, rather than deleting a lock someone else holds.
|
|
194
|
+
unreadableSince ??= Date.now();
|
|
195
|
+
if (Date.now() - unreadableSince > LOCK_STALE_GRACE_MS) {
|
|
196
|
+
try { rmSync(lockPath, { force: true }); } catch { /* another waiter got there first */ }
|
|
197
|
+
unreadableSince = undefined;
|
|
198
|
+
}
|
|
199
|
+
Bun.sleepSync(5);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
unreadableSince = undefined;
|
|
203
|
+
|
|
204
|
+
let ownerAlive = true;
|
|
205
|
+
try { process.kill(ownerPid, 0); } catch { ownerAlive = false; }
|
|
206
|
+
if (!ownerAlive) {
|
|
207
|
+
try { rmSync(lockPath, { force: true }); } catch { /* another waiter got there first */ }
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Bun.sleepSync(10);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
return fn();
|
|
216
|
+
} finally {
|
|
217
|
+
_heldLocks.delete(lockPath);
|
|
218
|
+
try { rmSync(lockPath, { force: true }); } catch { /* best effort */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Lock name for the registry-wide lock. Deliberately not a legal socket name:
|
|
223
|
+
* `assertPathSafeName` accepts it as a path segment, while `assertValidResourceName`
|
|
224
|
+
* rejects a leading dot for a real socket, so it can never collide with one. Its lock
|
|
225
|
+
* file does not end in `.json`, so `listNamedSockets()` never sees it either. */
|
|
226
|
+
const REGISTRY_LOCK_NAME = ".registry";
|
|
227
|
+
|
|
228
|
+
/** Serialize work that reads or writes the registry as a whole, rather than one entry.
|
|
229
|
+
*
|
|
230
|
+
* Automatic port allocation is `max(existing ports) + 1`, which reads every entry —
|
|
231
|
+
* so the per-entry lock does not help when the contenders are *different* names. Three
|
|
232
|
+
* processes creating three `mcast` links at once all read an empty registry and all
|
|
233
|
+
* take 4000, silently collapsing three segments into one shared group. A repro hit
|
|
234
|
+
* that on 15 of 15 rounds.
|
|
235
|
+
*
|
|
236
|
+
* Always taken *inside* `withEntryLock()`, never the other way round, so the lock
|
|
237
|
+
* order is fixed and two holders cannot deadlock. */
|
|
238
|
+
function withRegistryLock<T>(fn: () => T): T {
|
|
239
|
+
return withEntryLock(REGISTRY_LOCK_NAME, fn);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Read an entry straight from disk, bypassing the in-memory cache.
|
|
243
|
+
*
|
|
244
|
+
* The cache is a write-through workaround for Bun's Windows FS caching, and it is
|
|
245
|
+
* correct within one process — but under `withEntryLock()` the file may have been
|
|
246
|
+
* changed by *another* process since we last read it, so a cached copy is exactly
|
|
247
|
+
* the wrong thing to modify. */
|
|
248
|
+
function reloadEntry(name: string): SocketEntry | undefined {
|
|
249
|
+
try {
|
|
250
|
+
const entry = normalizeEntry(JSON.parse(readFileSync(socketPath(name), "utf-8")));
|
|
251
|
+
_cache.set(name, entry);
|
|
252
|
+
return entry;
|
|
253
|
+
} catch {
|
|
254
|
+
_cache.delete(name);
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Every port the registry has handed out — read from disk *and* from this process's
|
|
260
|
+
* cache.
|
|
261
|
+
*
|
|
262
|
+
* `listNamedSockets()` seeds itself from `_cache` and only reads disk for names it does
|
|
263
|
+
* not already hold, so a cached entry whose on-disk port has since changed is never
|
|
264
|
+
* re-read. Allocating from it can then hand out a port another process is already
|
|
265
|
+
* using: cache `x` at 4000, let another process move `x` to 4001, and the next
|
|
266
|
+
* allocation here picks 4001.
|
|
267
|
+
*
|
|
268
|
+
* Reading only from disk is not the fix either — the cache exists because Bun on
|
|
269
|
+
* Windows can return stale data from a read that immediately follows a write, so a
|
|
270
|
+
* fresh scan can miss entries *this* process just wrote. The two sources go stale in
|
|
271
|
+
* opposite directions, so allocation unions them. That can only over-estimate the
|
|
272
|
+
* maximum, which wastes a port number; trusting either one alone under-estimates it
|
|
273
|
+
* and collides. */
|
|
274
|
+
function portsInUse(): number[] {
|
|
275
|
+
const ports: number[] = [];
|
|
276
|
+
for (const entry of _cache.values()) {
|
|
277
|
+
if (typeof entry.port === "number") ports.push(entry.port);
|
|
278
|
+
}
|
|
279
|
+
const dir = getSocketRegistryDir();
|
|
280
|
+
try {
|
|
281
|
+
for (const file of readdirSync(dir)) {
|
|
282
|
+
if (!file.endsWith(".json")) continue;
|
|
283
|
+
try {
|
|
284
|
+
const raw = JSON.parse(readFileSync(join(dir, file), "utf-8")) as Partial<SocketEntry>;
|
|
285
|
+
if (typeof raw.port === "number") ports.push(raw.port);
|
|
286
|
+
} catch { /* unreadable: it holds no usable link, so it claims no port */ }
|
|
287
|
+
}
|
|
288
|
+
} catch { /* no registry directory yet */ }
|
|
289
|
+
return ports;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Next free port. Callers hold the registry-wide lock, so the scan above and the write
|
|
293
|
+
* that follows it are one critical section against every other name. */
|
|
294
|
+
function allocatePort(): number {
|
|
295
|
+
const used = portsInUse();
|
|
296
|
+
if (used.length === 0) return DEFAULT_START_PORT;
|
|
297
|
+
return Math.max(...used) + 1;
|
|
69
298
|
}
|
|
70
299
|
|
|
71
300
|
export function createNamedSocket(
|
|
72
301
|
name: string,
|
|
73
|
-
opts?: { mode?:
|
|
302
|
+
opts?: { mode?: SocketMode; port?: number; mcastGroup?: string; autoCreated?: boolean },
|
|
303
|
+
): SocketEntry {
|
|
304
|
+
return withEntryLock(name, () => {
|
|
305
|
+
if (reloadEntry(name)) {
|
|
306
|
+
throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" already exists`);
|
|
307
|
+
}
|
|
308
|
+
return createEntry(name, opts);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Build and persist a new entry. Caller holds the entry lock and has already
|
|
313
|
+
* established that the name is free. */
|
|
314
|
+
function createEntry(
|
|
315
|
+
name: string,
|
|
316
|
+
opts?: { mode?: SocketMode; port?: number; mcastGroup?: string; autoCreated?: boolean },
|
|
74
317
|
): SocketEntry {
|
|
75
|
-
|
|
76
|
-
|
|
318
|
+
// The name becomes a filename under networks/ and is echoed back in every
|
|
319
|
+
// `--add-network socket::<name>`, so it is validated before anything is written —
|
|
320
|
+
// `networks sockets create --help` used to persist a socket called "--help" (#156).
|
|
321
|
+
assertValidResourceName(name, "named socket");
|
|
322
|
+
|
|
323
|
+
const mode = opts?.mode ?? defaultSocketMode();
|
|
324
|
+
// Silently dropping an option the caller passed is the class of bug this whole
|
|
325
|
+
// area is about, so a group that cannot apply is an error rather than a no-op.
|
|
326
|
+
if (opts?.mcastGroup !== undefined && mode !== "mcast") {
|
|
327
|
+
throw new QuickCHRError(
|
|
328
|
+
"INVALID_NETWORK",
|
|
329
|
+
`mcastGroup only applies to mode "mcast" — named socket "${name}" is ${mode}.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
// The CLI rejects this; the library has to as well, or it can persist an entry whose
|
|
333
|
+
// port is meaningless and which nothing ever reports.
|
|
334
|
+
if (opts?.port !== undefined && mode === "dgram") {
|
|
335
|
+
throw new QuickCHRError(
|
|
336
|
+
"INVALID_NETWORK",
|
|
337
|
+
`port does not apply to mode "dgram" — named socket "${name}" is a unix datagram pair and uses no port.`,
|
|
338
|
+
);
|
|
77
339
|
}
|
|
78
340
|
|
|
79
|
-
const mode = opts?.mode ?? "mcast";
|
|
80
|
-
const port = opts?.port ?? allocatePort(listNamedSockets());
|
|
81
341
|
const mcastGroup = mode === "mcast" ? (opts?.mcastGroup ?? DEFAULT_MCAST_GROUP) : undefined;
|
|
82
342
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
343
|
+
// Fail at create, not at spawn: a path over sun_path only surfaces as a QEMU
|
|
344
|
+
// startup error that names a path the caller never chose.
|
|
345
|
+
if (mode === "dgram") assertEndpointPathsFit(name);
|
|
346
|
+
|
|
347
|
+
const build = (port: number | undefined): SocketEntry => {
|
|
348
|
+
const entry: SocketEntry = {
|
|
349
|
+
name,
|
|
350
|
+
mode,
|
|
351
|
+
port,
|
|
352
|
+
mcastGroup,
|
|
353
|
+
createdAt: new Date().toISOString(),
|
|
354
|
+
members: [],
|
|
355
|
+
endpoints: mode === "mcast" ? undefined : [null, null],
|
|
356
|
+
autoCreated: opts?.autoCreated ?? false,
|
|
357
|
+
};
|
|
358
|
+
saveEntry(entry);
|
|
359
|
+
return entry;
|
|
91
360
|
};
|
|
92
361
|
|
|
93
|
-
|
|
94
|
-
|
|
362
|
+
// A dgram link needs no port, and an explicit one is the caller's to pick — neither
|
|
363
|
+
// reads the rest of the registry, so neither needs the registry-wide lock.
|
|
364
|
+
if (mode === "dgram") return build(undefined);
|
|
365
|
+
if (opts?.port !== undefined) return build(opts.port);
|
|
366
|
+
|
|
367
|
+
// Allocating reads every entry, so the read and the write that follows it have to be
|
|
368
|
+
// one critical section against every other name.
|
|
369
|
+
return withRegistryLock(() => build(allocatePort()));
|
|
95
370
|
}
|
|
96
371
|
|
|
97
372
|
export function getNamedSocket(name: string): SocketEntry | undefined {
|
|
@@ -128,32 +403,130 @@ export function listNamedSockets(): SocketEntry[] {
|
|
|
128
403
|
}
|
|
129
404
|
|
|
130
405
|
export function removeNamedSocket(name: string): boolean {
|
|
406
|
+
return withEntryLock(name, () => {
|
|
407
|
+
// Removing the entry also unlinks the endpoint sockets, and a peer's
|
|
408
|
+
// `remote.path` then names nothing — so removing a link out from under running
|
|
409
|
+
// machines breaks it immediately and silently. Members are dropped on
|
|
410
|
+
// stop/remove/clean, so this only refuses a link that is genuinely in use.
|
|
411
|
+
const entry = reloadEntry(name);
|
|
412
|
+
if (entry && entry.members.length > 0) {
|
|
413
|
+
throw new QuickCHRError(
|
|
414
|
+
"STATE_ERROR",
|
|
415
|
+
`Named socket "${name}" is in use by ${entry.members.join(" and ")} — ` +
|
|
416
|
+
`stop ${entry.members.length > 1 ? "those machines" : "that machine"} first.`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return removeEntry(name);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function removeEntry(name: string): boolean {
|
|
131
424
|
const wasCached = _cache.has(name);
|
|
132
425
|
_cache.delete(name);
|
|
426
|
+
// QEMU does not unlink its unix sockets on exit — it rebinds over a stale one, so
|
|
427
|
+
// they are harmless while the link exists, but removing the link should not leave
|
|
428
|
+
// them behind in the registry directory.
|
|
429
|
+
for (const slot of [0, 1]) {
|
|
430
|
+
try { rmSync(socketEndpointPath(name, slot)); } catch { /* absent, or never a dgram link */ }
|
|
431
|
+
}
|
|
133
432
|
const path = socketPath(name);
|
|
134
433
|
if (!existsSync(path)) return wasCached;
|
|
135
434
|
rmSync(path);
|
|
136
435
|
return true;
|
|
137
436
|
}
|
|
138
437
|
|
|
438
|
+
/** Unix datagram socket path for one endpoint of a `dgram` link.
|
|
439
|
+
*
|
|
440
|
+
* Keyed by slot, not by machine name: the first machine to start has to name its
|
|
441
|
+
* peer's path before that peer exists, and a slot is the only thing both ends can
|
|
442
|
+
* agree on in advance. (QEMU is happy for `remote.path` to be missing at spawn —
|
|
443
|
+
* verified in either start order.) */
|
|
444
|
+
export function socketEndpointPath(name: string, slot: number): string {
|
|
445
|
+
assertPathSafeName(name, "named socket");
|
|
446
|
+
return join(getSocketRegistryDir(), `${name}.${slot}.sock`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function assertEndpointPathsFit(name: string): void {
|
|
450
|
+
for (const slot of [0, 1]) {
|
|
451
|
+
const path = socketEndpointPath(name, slot);
|
|
452
|
+
if (Buffer.byteLength(path) > SUN_PATH_MAX) {
|
|
453
|
+
throw new QuickCHRError(
|
|
454
|
+
"INVALID_NETWORK",
|
|
455
|
+
`Unix socket path for "${name}" is ${Buffer.byteLength(path)} bytes, over the ${SUN_PATH_MAX}-byte limit: ${path}. ` +
|
|
456
|
+
`Use a shorter QUICKCHR_DATA_DIR or socket name, or create the socket with --mode listen-connect.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Slot this machine holds on a two-member socket, or `undefined` for `mcast`. */
|
|
463
|
+
export function getSocketSlot(entry: SocketEntry, machineName: string): number | undefined {
|
|
464
|
+
if (!entry.endpoints) return undefined;
|
|
465
|
+
const slot = entry.endpoints.indexOf(machineName);
|
|
466
|
+
return slot === -1 ? undefined : slot;
|
|
467
|
+
}
|
|
468
|
+
|
|
139
469
|
export function addSocketMember(name: string, machineName: string): void {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
470
|
+
withEntryLock(name, () => {
|
|
471
|
+
const entry = reloadEntry(name);
|
|
472
|
+
if (!entry) {
|
|
473
|
+
throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" not found`);
|
|
474
|
+
}
|
|
475
|
+
claimEndpoint(entry, machineName);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Create the link if it does not exist, then join it — under one lock.
|
|
480
|
+
*
|
|
481
|
+
* `start()` used to test-then-create, which two concurrent starts both pass, so both
|
|
482
|
+
* create the entry and one silently overwrites the other's slot claim. */
|
|
483
|
+
export function joinNamedSocket(name: string, machineName: string, opts?: { autoCreated?: boolean }): SocketEntry {
|
|
484
|
+
return withEntryLock(name, () => {
|
|
485
|
+
const entry = reloadEntry(name) ?? createEntry(name, { autoCreated: opts?.autoCreated ?? false });
|
|
486
|
+
claimEndpoint(entry, machineName);
|
|
487
|
+
return entry;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Give `machineName` an endpoint and persist. Caller holds the entry lock. */
|
|
492
|
+
function claimEndpoint(entry: SocketEntry, machineName: string): void {
|
|
493
|
+
let changed = false;
|
|
494
|
+
// `mcast` has no endpoints and no cap; the slot array *is* the capacity.
|
|
495
|
+
if (entry.endpoints && !entry.endpoints.includes(machineName)) {
|
|
496
|
+
const free = entry.endpoints.indexOf(null);
|
|
497
|
+
if (free === -1) {
|
|
498
|
+
const held = entry.endpoints.filter((m): m is string => m !== null);
|
|
499
|
+
throw new QuickCHRError(
|
|
500
|
+
"NETWORK_UNAVAILABLE",
|
|
501
|
+
`Named socket "${entry.name}" is a ${entry.mode} link and carries ${entry.endpoints.length} machines; ` +
|
|
502
|
+
`${held.join(" and ")} already hold both ends. ` +
|
|
503
|
+
`Stop one of them, or create an N-way segment with 'quickchr networks sockets create <name> --mode mcast'.`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
entry.endpoints[free] = machineName;
|
|
507
|
+
changed = true;
|
|
143
508
|
}
|
|
144
509
|
if (!entry.members.includes(machineName)) {
|
|
145
510
|
entry.members.push(machineName);
|
|
146
|
-
|
|
511
|
+
changed = true;
|
|
147
512
|
}
|
|
513
|
+
if (changed) saveEntry(entry);
|
|
148
514
|
}
|
|
149
515
|
|
|
150
516
|
export function removeSocketMember(name: string, machineName: string): void {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
517
|
+
withEntryLock(name, () => {
|
|
518
|
+
const entry = reloadEntry(name);
|
|
519
|
+
if (!entry) return;
|
|
520
|
+
entry.members = entry.members.filter((m) => m !== machineName);
|
|
521
|
+
if (entry.endpoints) {
|
|
522
|
+
entry.endpoints = entry.endpoints.map((m) => (m === machineName ? null : m));
|
|
523
|
+
}
|
|
524
|
+
if (entry.members.length === 0 && entry.autoCreated) {
|
|
525
|
+
// removeEntry, not removeNamedSocket: the in-use guard there re-reads from
|
|
526
|
+
// disk, which still shows the member we just dropped in memory.
|
|
527
|
+
removeEntry(name);
|
|
528
|
+
} else {
|
|
529
|
+
saveEntry(entry);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
159
532
|
}
|