@tikoci/quickchr 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/images.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Image download, ZIP extraction, and cache management.
3
3
  */
4
4
 
5
- import { existsSync, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
5
+ import { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -11,7 +11,18 @@ import { downloadToFile } from "./download.ts";
11
11
  import { getCacheDir, ensureDir } from "./state.ts";
12
12
  import { createLogger, type ProgressLogger } from "./log.ts";
13
13
  import { extractZip } from "./zip.ts";
14
- import { assertSufficientQuickchrStorage } from "./storage.ts";
14
+ import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
15
+ import { renameWithRetry } from "./rename.ts";
16
+
17
+ /** A cache hit is a use: bump the mtime so automatic eviction counts the entry as
18
+ * recently used while this operation reads it (#199). Best-effort: a read-only cache
19
+ * still works. */
20
+ export function touchCacheEntry(path: string): void {
21
+ try {
22
+ const now = new Date();
23
+ utimesSync(path, now, now);
24
+ } catch { /* read-only cache */ }
25
+ }
15
26
 
16
27
  function finalizeExtractedImage(zipPath: string, imgPath: string): string {
17
28
  if (existsSync(imgPath) && existsSync(zipPath)) {
@@ -118,7 +129,7 @@ export async function extractImage(
118
129
  if (!(await isUsableCachedImage(extractedPath))) {
119
130
  throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
120
131
  }
121
- renameSync(extractedPath, imgPath);
132
+ renameWithRetry(extractedPath, imgPath);
122
133
  } finally {
123
134
  rmSync(tempDir, { recursive: true, force: true });
124
135
  }
@@ -138,21 +149,29 @@ export async function ensureCachedImage(
138
149
  if (await isUsableCachedImage(imgPath)) {
139
150
  const log = logger ?? createLogger();
140
151
  log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
152
+ touchCacheEntry(imgPath);
141
153
  return imgPath;
142
154
  }
143
155
  if (existsSync(imgPath)) unlinkSync(imgPath);
144
- assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
145
- const zipPath = await downloadImage(version, arch, cacheDir, logger);
146
- const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
147
- return finalizeExtractedImage(zipPath, extractedImgPath);
148
- }
149
-
150
- /** Copy a cached image to a machine's working directory as disk.img. */
151
- export function copyImageToMachine(cachedImgPath: string, machineDir: string): string {
152
- ensureDir(machineDir);
153
- const dest = join(machineDir, "disk.img");
154
- copyFileSync(cachedImgPath, dest);
155
- return dest;
156
+ const log = logger ?? createLogger();
157
+ const base = chrImageBasename(version, arch);
158
+ const lease = admitStorage(
159
+ `cache CHR ${version} (${arch})`,
160
+ {
161
+ parts: [{ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache }],
162
+ protectVersions: [version],
163
+ pins: [`${base}.img`, `${base}.img.zip`],
164
+ cacheDir: cache,
165
+ },
166
+ { logger: (message: string) => log.status(message) },
167
+ );
168
+ try {
169
+ const zipPath = await downloadImage(version, arch, cacheDir, logger);
170
+ const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
171
+ return finalizeExtractedImage(zipPath, extractedImgPath);
172
+ } finally {
173
+ lease.release();
174
+ }
156
175
  }
157
176
 
158
177
  /** List cached images. */
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Cross-process file locks: the per-machine lifecycle lock and the data-dir-wide
3
+ * allocation lock.
4
+ *
5
+ * One primitive for both. A lock is a directory holding an append-only log of
6
+ * numbered records, and the **highest number is the lock's state**: a live holder's
7
+ * record means held; a released marker, a dead holder's record, or no record at all
8
+ * means free. Taking the lock is creating record top+1, published with `link()` so only
9
+ * one contender can create it and it never exists half-written. Releasing appends a
10
+ * released marker. Superseded records are collected by the holder while it holds.
11
+ *
12
+ * No recovery deletes a record another process may hold. A path has no
13
+ * compare-and-delete, so deleting a dead holder's lock by path leaves a window between
14
+ * judging it dead and deleting it, and a successor installed in that window is
15
+ * deleted too. Rename-aside-and-compare had that window, and so did a reap guard
16
+ * recovered by age (Codex review of #190, both reproduced). Here, recovering from a
17
+ * dead holder is just claiming the next number.
18
+ *
19
+ * **A claim is checked against the exact record it judged free**, not just that
20
+ * record's number. Every record carries a unique token, so once created, a claim
21
+ * stands only if nothing higher exists and its predecessor is still byte-for-byte
22
+ * what was judged. That turns away a contender that slept through other claims, or
23
+ * through the log being deleted and started again. A number can recur; a record
24
+ * cannot.
25
+ *
26
+ * **A machine's lock lives and dies with `machines/<name>/`**, so nothing is kept
27
+ * after a machine is gone. Only a holder deletes a lock directory: `remove()` takes
28
+ * the machine's directory with its lock inside, and a failed create discards what it
29
+ * made.
30
+ *
31
+ * A directory can be removed and a same-named one created while some process is
32
+ * paused holding paths into the old lock. So **every record is named for the lock's
33
+ * incarnation**, `<id>.<gen>`, with the id published once per directory. A stale
34
+ * process's paths cannot reach the new incarnation's records, and a claim re-checks
35
+ * that its id is still current before it stands. On top of that, a holder does all its
36
+ * destructive cleanup before it publishes its released marker, because from that
37
+ * moment it is no longer the holder.
38
+ *
39
+ * A holder records `{ pid, op, at, token }`. The pid decides liveness, the op and age
40
+ * go into `MACHINE_LOCKED` so the refusal can say what is holding the machine, and a
41
+ * creation carries its port/MAC reservation (#140).
42
+ *
43
+ * The lock is deliberately not reentrant. Two async operations in one process are as
44
+ * much in conflict as two processes are — a `stop()` and a `start()` in one
45
+ * `Promise.all` must be ordered — so a lock held by this pid is a lock held by someone
46
+ * else. Code that already holds a machine's lock calls the `*UnderLock` lifecycle
47
+ * helpers in quickchr.ts rather than the public methods.
48
+ */
49
+
50
+ import { linkSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
51
+ import { dirname, join } from "node:path";
52
+ import { QuickCHRError } from "./types.ts";
53
+ import { getDataDir, getMachinesDir } from "./state.ts";
54
+
55
+ /** What a lock holder is doing. `foreground` is a start whose QEMU runs attached to
56
+ * the caller's terminal: it holds the lock until the VM exits, so waiting for it is
57
+ * never useful. */
58
+ export type LockOperation =
59
+ | "add"
60
+ | "start"
61
+ | "foreground"
62
+ | "stop"
63
+ | "shutdown"
64
+ | "clean"
65
+ | "remove"
66
+ | "device-mode"
67
+ | "remove-orphan"
68
+ | "allocate"
69
+ | "storage";
70
+
71
+ /** Resources a machine creation has claimed but not yet persisted in `machine.json`.
72
+ * Carried in the creator's machine lock, so it lives exactly as long as the create
73
+ * does and dies with a crashed creator's pid (#140). */
74
+ export interface ResourceReservation {
75
+ portBase: number;
76
+ macs: string[];
77
+ }
78
+
79
+ export interface LockRecord {
80
+ pid: number;
81
+ op: LockOperation;
82
+ at: string;
83
+ token: string;
84
+ reserve?: ResourceReservation;
85
+ /** A released marker: the holder before it let go. */
86
+ released?: true;
87
+ }
88
+
89
+ export interface HeldLock {
90
+ readonly path: string;
91
+ /** Append a released marker after this holder's record. Idempotent, and a no-op
92
+ * once the holder has deleted the lock's directory. */
93
+ release(): void;
94
+ }
95
+
96
+ export interface AcquireLockOptions {
97
+ /** How long to wait for a live holder. 0 (the default) fails immediately. */
98
+ waitMs?: number;
99
+ /** A pre-0.5 `.start-lock` to honour alongside this lock. */
100
+ legacyPath?: string;
101
+ /** Called once, when the first attempt finds a live holder and waiting begins. */
102
+ onWait?: (holder: LockRecord) => void;
103
+ reserve?: ResourceReservation;
104
+ /** Noun for the refusal message, e.g. `Machine "foo"`. */
105
+ subject?: string;
106
+ }
107
+
108
+ /** The pre-0.5 lock file, in the machine directory. For one release a live holder is
109
+ * honoured, so a machine locked by an older quickchr is not taken from under it. A
110
+ * dead one is ignored, not deleted: the older quickchr recovers its own stale file. */
111
+ const LEGACY_MACHINE_LOCK_FILE = ".start-lock";
112
+ const POLL_MS = 100;
113
+ /** A record that does not parse only counts as abandoned once it is this old. An older
114
+ * quickchr creates `.start-lock` empty and fills it in with a second syscall. */
115
+ const UNREADABLE_GRACE_MS = 500;
116
+
117
+ /** A machine's lock, inside its directory so it goes when the machine does. */
118
+ export function machineLockPath(machineDir: string): string {
119
+ return join(machineDir, ".lock");
120
+ }
121
+
122
+ /** Where an older quickchr locks a machine. */
123
+ export function legacyMachineLockPath(machineDir: string): string {
124
+ return join(machineDir, LEGACY_MACHINE_LOCK_FILE);
125
+ }
126
+
127
+ /** Held across the read → probe → claim window of a machine creation, so two creates
128
+ * cannot pick the same port block or MAC (#140). Lives in the data dir root, not in
129
+ * `machines/`, where it would be listed as a machine. One fixed directory: its log
130
+ * collects down to two records. */
131
+ export function allocationLockPath(): string {
132
+ return join(getDataDir(), ".allocate.lock");
133
+ }
134
+
135
+ /** Test seam: called after each change this module makes inside a lock directory, so
136
+ * a test can interleave another contender at exactly that point. */
137
+ let mutationObserver: ((op: "link" | "unlink", path: string) => void) | undefined;
138
+ export function _observeLockMutations(fn: typeof mutationObserver): void {
139
+ mutationObserver = fn;
140
+ }
141
+
142
+ function parseRecord(text: string): LockRecord | undefined {
143
+ const trimmed = text.trim();
144
+ // A legacy `.start-lock` holds a bare pid.
145
+ if (/^\d+$/.test(trimmed)) {
146
+ return { pid: Number.parseInt(trimmed, 10), op: "start", at: "", token: "" };
147
+ }
148
+ try {
149
+ const parsed = JSON.parse(trimmed) as Partial<LockRecord>;
150
+ if (typeof parsed.pid === "number" && parsed.pid > 0 && typeof parsed.op === "string") {
151
+ return parsed as LockRecord;
152
+ }
153
+ } catch { /* fall through */ }
154
+ return undefined;
155
+ }
156
+
157
+ type Inspection =
158
+ | { state: "absent" }
159
+ | { state: "live"; record: LockRecord }
160
+ | { state: "free"; raw: string }
161
+ | { state: "unknown" };
162
+
163
+ function isAlive(pid: number): boolean {
164
+ try {
165
+ process.kill(pid, 0);
166
+ return true;
167
+ } catch (e) {
168
+ // EPERM: the process exists but belongs to someone else.
169
+ return (e as NodeJS.ErrnoException).code === "EPERM";
170
+ }
171
+ }
172
+
173
+ function readRaw(path: string): string | undefined {
174
+ try {
175
+ return readFileSync(path, "utf-8");
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ /** One record file. `unknown` is a file whose holder cannot be checked: unreadable
182
+ * (EACCES from another user's lock, EISDIR, I/O), or unparseable and new enough that
183
+ * its writer may not have finished. */
184
+ function inspect(path: string): Inspection {
185
+ let raw: string;
186
+ try {
187
+ raw = readFileSync(path, "utf-8");
188
+ } catch (e) {
189
+ return (e as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "unknown" };
190
+ }
191
+ const record = parseRecord(raw);
192
+ if (!record) {
193
+ let ageMs = Number.POSITIVE_INFINITY;
194
+ try { ageMs = Date.now() - statSync(path).mtimeMs; } catch { /* gone */ }
195
+ return ageMs > UNREADABLE_GRACE_MS ? { state: "free", raw } : { state: "unknown" };
196
+ }
197
+ if (record.released) return { state: "free", raw };
198
+ return isAlive(record.pid) ? { state: "live", record } : { state: "free", raw };
199
+ }
200
+
201
+ function missing(lockDir: string): QuickCHRError {
202
+ return new QuickCHRError("MACHINE_NOT_FOUND", `${dirname(lockDir)} no longer exists — the machine was removed.`);
203
+ }
204
+
205
+ /** The record numbers of one incarnation of a lock. A lock whose parent directory has
206
+ * gone belongs to a machine that was removed. */
207
+ function generations(lockDir: string, id: string): number[] {
208
+ const prefix = `${id}.`;
209
+ try {
210
+ return readdirSync(lockDir)
211
+ .filter((f) => f.startsWith(prefix) && /^\d+$/.test(f.slice(prefix.length)))
212
+ .map((f) => Number(f.slice(prefix.length)));
213
+ } catch (e) {
214
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(lockDir);
215
+ throw e;
216
+ }
217
+ }
218
+
219
+ function recordPath(lockDir: string, id: string, gen: number): string {
220
+ return join(lockDir, `${id}.${gen}`);
221
+ }
222
+
223
+ /** The lock directory's incarnation id, published once when the directory is first
224
+ * used, or undefined if it has none yet and `create` is false.
225
+ *
226
+ * Every record is named `<id>.<gen>`. A machine directory can be removed and created
227
+ * again under the same name, and a process paused across that still holds paths into
228
+ * the old lock. With the id in every name, those paths cannot reach the new lock's
229
+ * records: a collect, a rejected-claim cleanup or a released marker from a stale
230
+ * process touches only names nobody reads any more (Codex review of #190). */
231
+ function incarnation(lockDir: string, create: boolean): string | undefined {
232
+ const idPath = join(lockDir, "id");
233
+ const read = () => readRaw(idPath)?.trim() || undefined;
234
+ const existing = read();
235
+ if (existing || !create) return existing;
236
+ linkNew(idPath, crypto.randomUUID());
237
+ const id = read();
238
+ if (!id) throw missing(lockDir);
239
+ return id;
240
+ }
241
+
242
+ /** The lock directory, created if its parent exists. Never the parent: a waiter must
243
+ * not bring a removed machine's directory back as an empty orphan. */
244
+ function ensureLockDir(lockDir: string): void {
245
+ try {
246
+ mkdirSync(lockDir);
247
+ } catch (e) {
248
+ const code = (e as NodeJS.ErrnoException).code;
249
+ if (code === "ENOENT") throw missing(lockDir);
250
+ if (code !== "EEXIST") throw e;
251
+ }
252
+ }
253
+
254
+ /** Publish `content` at `path` only if nothing is there. Written to a temp file and
255
+ * `link()`ed into place, so the file never exists half-written. */
256
+ function linkNew(path: string, content: string): boolean {
257
+ const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
258
+ try {
259
+ writeFileSync(tmp, content);
260
+ } catch (e) {
261
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(dirname(path));
262
+ throw e;
263
+ }
264
+ try {
265
+ linkSync(tmp, path);
266
+ mutationObserver?.("link", path);
267
+ return true;
268
+ } catch (e) {
269
+ const code = (e as NodeJS.ErrnoException).code;
270
+ if (code === "EEXIST") return false;
271
+ if (code === "ENOENT") throw missing(dirname(path));
272
+ throw e;
273
+ } finally {
274
+ try { unlinkSync(tmp); } catch { /* ignore */ }
275
+ }
276
+ }
277
+
278
+ /** Delete superseded records of one incarnation below `keepFrom`. Only a holder calls
279
+ * this, and only while it holds the lock: never the top, and never in a replacement
280
+ * directory, whose records carry another id. */
281
+ function collect(lockDir: string, id: string, keepFrom: number): void {
282
+ let gens: number[];
283
+ try { gens = generations(lockDir, id); } catch { return; }
284
+ for (const gen of gens) {
285
+ if (gen >= keepFrom) continue;
286
+ const path = recordPath(lockDir, id, gen);
287
+ try {
288
+ unlinkSync(path);
289
+ mutationObserver?.("unlink", path);
290
+ } catch { /* another collector got it */ }
291
+ }
292
+ }
293
+
294
+ type Top = { gen: number; seen: Inspection } | undefined;
295
+
296
+ /** The top record of an incarnation, or `"vanished"` when it went between listing and
297
+ * reading — a holder collected it — and the caller should look again. */
298
+ function readTop(lockDir: string, id: string): Top | "vanished" {
299
+ const gens = generations(lockDir, id);
300
+ if (gens.length === 0) return undefined;
301
+ const gen = Math.max(...gens);
302
+ const seen = inspect(recordPath(lockDir, id, gen));
303
+ return seen.state === "absent" ? "vanished" : { gen, seen };
304
+ }
305
+
306
+ /** Try to take incarnation `id` of the lock as record `gen`, having judged record
307
+ * `gen - 1` free with contents `judgedRaw` (or the incarnation empty, for `gen` 1 with
308
+ * no `judgedRaw`). Returns the lock, or undefined when another contender got there
309
+ * first or the judgement turned out to be stale.
310
+ *
311
+ * The claim stands only if, once created, it is the top, it follows exactly the
312
+ * record judged, and `id` is still the directory's incarnation. When `id` is still
313
+ * current after the first two checks, this record is the lock's live top, so nobody
314
+ * else holds it and nobody can remove the directory in between. */
315
+ export function claim(
316
+ lockDir: string,
317
+ id: string,
318
+ gen: number,
319
+ op: LockOperation,
320
+ judgedRaw?: string,
321
+ reserve?: ResourceReservation,
322
+ ): HeldLock | undefined {
323
+ const record: LockRecord = {
324
+ pid: process.pid,
325
+ op,
326
+ at: new Date().toISOString(),
327
+ token: crypto.randomUUID(),
328
+ ...(reserve ? { reserve } : {}),
329
+ };
330
+ const raw = `${JSON.stringify(record)}\n`;
331
+ const path = recordPath(lockDir, id, gen);
332
+ if (!linkNew(path, raw)) return undefined;
333
+
334
+ const gens = generations(lockDir, id);
335
+ const isTop = !gens.some((g) => g > gen);
336
+ const followsJudged = judgedRaw === undefined
337
+ ? gen === 1 && gens.length === 1
338
+ : readRaw(recordPath(lockDir, id, gen - 1)) === judgedRaw;
339
+ if (!isTop || !followsJudged || incarnation(lockDir, false) !== id) {
340
+ // Our own name, in our incarnation: if the directory has been replaced since,
341
+ // this name means nothing in the new one.
342
+ try { unlinkSync(path); } catch { /* collected already, or the directory went */ }
343
+ mutationObserver?.("unlink", path);
344
+ return undefined;
345
+ }
346
+ collect(lockDir, id, gen);
347
+ return heldLock(lockDir, id, gen, record, raw);
348
+ }
349
+
350
+ function heldLock(lockDir: string, id: string, gen: number, record: LockRecord, raw: string): HeldLock {
351
+ let released = false;
352
+ return {
353
+ path: lockDir,
354
+ release() {
355
+ if (released) return;
356
+ released = true;
357
+ // Gone means this holder deleted the directory — remove(), or a failed create
358
+ // discarding what it made.
359
+ if (readRaw(recordPath(lockDir, id, gen)) !== raw) return;
360
+ // Everything destructive happens first, while this is still the holder. Once
361
+ // the marker is published another process can take the lock, remove the
362
+ // machine and let a same-named one start over, so nothing may be deleted by
363
+ // path after that point.
364
+ collect(lockDir, id, gen);
365
+ const marker: LockRecord = { ...record, released: true, at: new Date().toISOString() };
366
+ delete marker.reserve;
367
+ // EEXIST (a false return) means someone judged this holder dead and took over:
368
+ // nothing of ours is left to release. A missing directory means the same. Any
369
+ // other failure (ENOSPC, EACCES) means the marker was not written and the lock
370
+ // is still held, so it is thrown, and a later release() may try again.
371
+ try {
372
+ linkNew(recordPath(lockDir, id, gen + 1), `${JSON.stringify(marker)}\n`);
373
+ } catch (e) {
374
+ if (e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") return;
375
+ released = false;
376
+ throw e;
377
+ }
378
+ },
379
+ };
380
+ }
381
+
382
+ function describeHolder(holder: LockRecord | undefined): string {
383
+ if (!holder) return "another operation (its lock record is unreadable or still being written)";
384
+ const age = holder.at ? Math.max(0, Math.round((Date.now() - Date.parse(holder.at)) / 1000)) : undefined;
385
+ return `\`${holder.op}\` (pid ${holder.pid}${age !== undefined && !Number.isNaN(age) ? `) since ${age}s` : ")"}`;
386
+ }
387
+
388
+ function lockedError(subject: string, holder: LockRecord | undefined, waitedMs: number): QuickCHRError {
389
+ const waited = waitedMs > 0 ? ` after waiting ${Math.round(waitedMs / 1000)}s` : "";
390
+ const hint = holder?.op === "foreground"
391
+ ? " It is running in the foreground: quit it from that console (Ctrl-A X)."
392
+ : "";
393
+ return new QuickCHRError("MACHINE_LOCKED", `${subject} is locked by ${describeHolder(holder)}${waited}.${hint}`);
394
+ }
395
+
396
+ /** Whether a legacy `.start-lock` stands in the way, and its holder when that can be
397
+ * read. One that is unreadable, or so new its holder may still be writing it, blocks:
398
+ * going ahead in that window would run alongside an older quickchr. */
399
+ function legacyBlocker(legacyPath: string | undefined): { holder: LockRecord | undefined } | undefined {
400
+ if (!legacyPath) return undefined;
401
+ const seen = inspect(legacyPath);
402
+ if (seen.state === "live") return { holder: seen.record };
403
+ if (seen.state === "unknown") return { holder: undefined };
404
+ return undefined;
405
+ }
406
+
407
+ /** One attempt: the lock, or the holder in the way (undefined when its record cannot
408
+ * be read). */
409
+ function attempt(lockDir: string, op: LockOperation, opts: AcquireLockOptions): { lock: HeldLock } | { holder: LockRecord | undefined } {
410
+ ensureLockDir(lockDir);
411
+ for (;;) {
412
+ const id = incarnation(lockDir, true) as string;
413
+ const top = readTop(lockDir, id);
414
+ if (top === "vanished") continue;
415
+ if (top?.seen.state === "live") return { holder: top.seen.record };
416
+ if (top?.seen.state === "unknown") return { holder: undefined };
417
+ const judged = top?.seen.state === "free" ? top.seen.raw : undefined;
418
+ const lock = claim(lockDir, id, (top?.gen ?? 0) + 1, op, judged, opts.reserve);
419
+ if (!lock) continue;
420
+ const legacy = legacyBlocker(opts.legacyPath);
421
+ if (!legacy) return { lock };
422
+ lock.release();
423
+ return legacy;
424
+ }
425
+ }
426
+
427
+ /** Take a lock or fail now with `MACHINE_LOCKED`. A dead holder's lock is taken over
428
+ * on sight. Synchronous, for callers whose answer to a live holder is always "no" — a
429
+ * second `start` of one machine would only boot it twice. Throws `MACHINE_NOT_FOUND`
430
+ * if the lock's machine directory does not exist. */
431
+ export function acquireLockNow(lockPath: string, op: LockOperation, opts: Omit<AcquireLockOptions, "waitMs" | "onWait"> = {}): HeldLock {
432
+ const result = attempt(lockPath, op, opts);
433
+ if ("lock" in result) return result.lock;
434
+ throw lockedError(opts.subject ?? "Machine", result.holder, 0);
435
+ }
436
+
437
+ /** Take a lock, waiting up to `waitMs` for a live holder to release it. A dead holder's
438
+ * lock is taken over on sight; a `foreground` holder is never waited for, since it
439
+ * keeps the lock until its VM exits.
440
+ *
441
+ * Throws `MACHINE_LOCKED` naming the holder's operation, pid and age when the wait
442
+ * runs out, and `MACHINE_NOT_FOUND` if the machine is removed while this caller
443
+ * waits. */
444
+ export async function acquireLock(lockPath: string, op: LockOperation, opts: AcquireLockOptions = {}): Promise<HeldLock> {
445
+ const waitMs = opts.waitMs ?? 0;
446
+ const started = Date.now();
447
+ let notified = false;
448
+ for (;;) {
449
+ const result = attempt(lockPath, op, opts);
450
+ if ("lock" in result) return result.lock;
451
+ const { holder } = result;
452
+ const waited = Date.now() - started;
453
+ if (waited >= waitMs || holder?.op === "foreground") {
454
+ throw lockedError(opts.subject ?? "Machine", holder, waited);
455
+ }
456
+ if (!notified && holder) {
457
+ notified = true;
458
+ opts.onWait?.(holder);
459
+ }
460
+ await Bun.sleep(Math.min(POLL_MS, waitMs - waited));
461
+ }
462
+ }
463
+
464
+ /** The live holder of a lock, if any. */
465
+ export function currentHolder(lockPath: string): LockRecord | undefined {
466
+ for (;;) {
467
+ let top: Top | "vanished";
468
+ try {
469
+ const id = incarnation(lockPath, false);
470
+ if (!id) return undefined;
471
+ top = readTop(lockPath, id);
472
+ } catch {
473
+ return undefined; // no lock directory, so no holder
474
+ }
475
+ if (top === "vanished") continue;
476
+ return top?.seen.state === "live" ? top.seen.record : undefined;
477
+ }
478
+ }
479
+
480
+ /** Machines whose creation is in flight, with the port block and MACs each has
481
+ * claimed. Read from the creators' live machine locks; a crashed creator's
482
+ * reservation disappears with its pid. */
483
+ export function listReservations(exceptName?: string): Array<{ name: string } & ResourceReservation> {
484
+ const machinesDir = getMachinesDir();
485
+ let names: string[];
486
+ try {
487
+ names = readdirSync(machinesDir);
488
+ } catch {
489
+ return [];
490
+ }
491
+ const out: Array<{ name: string } & ResourceReservation> = [];
492
+ for (const name of names) {
493
+ if (name === exceptName) continue;
494
+ const holder = currentHolder(machineLockPath(join(machinesDir, name)));
495
+ if (holder?.reserve) out.push({ name, ...holder.reserve });
496
+ }
497
+ return out;
498
+ }
package/src/lib/names.ts CHANGED
@@ -70,3 +70,27 @@ export function isValidResourceName(name: string): boolean {
70
70
  return false;
71
71
  }
72
72
  }
73
+
74
+ /** POSIX-shell-safe form of `value`, for a command quickchr prints for someone to run.
75
+ *
76
+ * Needed because `assertValidResourceName()` only guards names at *creation*. A
77
+ * lookup has to keep older, looser names addressable — `isPathSafeName()` above
78
+ * rejects only empty, `.`, `..` and a path separator — so a real machine can be
79
+ * called `lab old` or `my;lab`, and `quickchr set lab old --device-mode rose` pasted
80
+ * into a shell addresses a machine called `lab`. Device-mode feature names have the
81
+ * same problem from the other end: `resolveDeviceModeOptions()` deliberately passes
82
+ * unknown features through for forward compatibility, so a typo with a space in it
83
+ * reaches the printed command intact.
84
+ *
85
+ * Values that need no quoting are returned unchanged, because the overwhelmingly
86
+ * common case is an ordinary name and a command in quotes reads like machine output
87
+ * rather than something a person would type.
88
+ *
89
+ * What this does **not** fix: a legacy name starting with `-` is read as a flag no
90
+ * matter how it is quoted, since quoting is the shell's business and the flag parse
91
+ * is quickchr's. New names cannot start with `-` (#156); an old one that does needs
92
+ * renaming, not escaping. */
93
+ export function shellQuote(value: string): string {
94
+ if (value.length > 0 && /^[A-Za-z0-9._:=@,+/-]+$/.test(value)) return value;
95
+ return `'${value.replaceAll("'", "'\\''")}'`;
96
+ }
@@ -38,15 +38,15 @@ export function allocatePortBlock(
38
38
  return requestedBase;
39
39
  }
40
40
 
41
- let base = DEFAULT_PORT_BASE;
42
- const sorted = [...usedBases].sort((a, b) => a - b);
41
+ return nextUnusedBase(DEFAULT_PORT_BASE, usedBases);
42
+ }
43
43
 
44
- for (const used of sorted) {
45
- if (base >= used && base < used + PORTS_PER_BLOCK) {
46
- base = used + PORTS_PER_BLOCK;
47
- }
44
+ /** The lowest base at or above `from` whose block overlaps no used block. */
45
+ function nextUnusedBase(from: number, usedBases: number[]): number {
46
+ let base = from;
47
+ for (const used of [...usedBases].sort((a, b) => a - b)) {
48
+ if (Math.abs(base - used) < PORTS_PER_BLOCK) base = used + PORTS_PER_BLOCK;
48
49
  }
49
-
50
50
  return base;
51
51
  }
52
52
 
@@ -219,7 +219,10 @@ export async function findAvailablePortBlock(
219
219
  if (conflicts.length === 0) {
220
220
  return base;
221
221
  }
222
- base += PORTS_PER_BLOCK;
222
+ // Step past the probed conflict without stepping onto a used block: a block
223
+ // that is free on the wire can still belong to a stopped machine or to a
224
+ // creation that has not bound its ports yet (#140).
225
+ base = nextUnusedBase(base + PORTS_PER_BLOCK, usedBases);
223
226
  }
224
227
 
225
228
  throw new QuickCHRError(
@@ -439,14 +442,11 @@ export function deriveMac(name: string, index: number, salt = 0): string {
439
442
  * the guarantee structural rather than probabilistic — a duplicate here is the exact
440
443
  * silent-L2 failure this exists to prevent, so it is worth not leaving to odds.
441
444
  *
442
- * `taken` is a snapshot, so two concurrent creations can each miss the other. That
443
- * only changes an outcome when the two would otherwise have collided — i.e. on top
444
- * of that 2^-40 event — but the window is real. It is the same read-then-write
445
- * window the port allocator has on the adjacent line (`getUsedPortBases()` →
446
- * `findAvailablePortBlock()` → `saveMachine()`), where it fires routinely and
447
- * breaks starts outright: #140. Both allocators want one lock around
448
- * read-allocate-persist; adding a second, MAC-only lock here would leave the
449
- * frequent half of the race unfixed while implying it was handled.
445
+ * `taken` is a snapshot, so it is only complete if no other creation is choosing at
446
+ * the same moment. Callers that create machines go through `claimNewMachine()` in
447
+ * quickchr.ts, which holds the allocation lock across this call and the port choice
448
+ * and adds in-flight creations' MACs to `taken` (#140). A new creation path must do
449
+ * the same rather than call this with a bare `getUsedMacs()`.
450
450
  */
451
451
  export function assignMacs(
452
452
  name: string,