@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.
@@ -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: "mcast" | "listen-connect";
27
+ mode: SocketMode;
14
28
  mcastGroup?: string;
15
- port: number;
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"]; port: number; createdAt: string }): SocketEntry {
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
- writeFileSync(socketPath(entry.name), JSON.stringify(entry, null, "\t") + "\n");
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
- function allocatePort(existing: SocketEntry[]): number {
66
- if (existing.length === 0) return DEFAULT_START_PORT;
67
- const maxPort = Math.max(...existing.map((e) => e.port));
68
- return maxPort + 1;
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?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string; autoCreated?: boolean },
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
- if (_cache.has(name) || existsSync(socketPath(name))) {
76
- throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" already exists`);
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
- const entry: SocketEntry = {
84
- name,
85
- mode,
86
- port,
87
- mcastGroup,
88
- createdAt: new Date().toISOString(),
89
- members: [],
90
- autoCreated: opts?.autoCreated ?? false,
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
- saveEntry(entry);
94
- return entry;
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
- const entry = getNamedSocket(name);
141
- if (!entry) {
142
- throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" not found`);
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
- saveEntry(entry);
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
- const entry = getNamedSocket(name);
152
- if (!entry) return;
153
- entry.members = entry.members.filter((m) => m !== machineName);
154
- if (entry.members.length === 0 && entry.autoCreated) {
155
- removeNamedSocket(name);
156
- } else {
157
- saveEntry(entry);
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
  }