@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
package/src/lib/lock.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
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
|
+
|
|
70
|
+
/** Resources a machine creation has claimed but not yet persisted in `machine.json`.
|
|
71
|
+
* Carried in the creator's machine lock, so it lives exactly as long as the create
|
|
72
|
+
* does and dies with a crashed creator's pid (#140). */
|
|
73
|
+
export interface ResourceReservation {
|
|
74
|
+
portBase: number;
|
|
75
|
+
macs: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LockRecord {
|
|
79
|
+
pid: number;
|
|
80
|
+
op: LockOperation;
|
|
81
|
+
at: string;
|
|
82
|
+
token: string;
|
|
83
|
+
reserve?: ResourceReservation;
|
|
84
|
+
/** A released marker: the holder before it let go. */
|
|
85
|
+
released?: true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HeldLock {
|
|
89
|
+
readonly path: string;
|
|
90
|
+
/** Append a released marker after this holder's record. Idempotent, and a no-op
|
|
91
|
+
* once the holder has deleted the lock's directory. */
|
|
92
|
+
release(): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AcquireLockOptions {
|
|
96
|
+
/** How long to wait for a live holder. 0 (the default) fails immediately. */
|
|
97
|
+
waitMs?: number;
|
|
98
|
+
/** A pre-0.5 `.start-lock` to honour alongside this lock. */
|
|
99
|
+
legacyPath?: string;
|
|
100
|
+
/** Called once, when the first attempt finds a live holder and waiting begins. */
|
|
101
|
+
onWait?: (holder: LockRecord) => void;
|
|
102
|
+
reserve?: ResourceReservation;
|
|
103
|
+
/** Noun for the refusal message, e.g. `Machine "foo"`. */
|
|
104
|
+
subject?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The pre-0.5 lock file, in the machine directory. For one release a live holder is
|
|
108
|
+
* honoured, so a machine locked by an older quickchr is not taken from under it. A
|
|
109
|
+
* dead one is ignored, not deleted: the older quickchr recovers its own stale file. */
|
|
110
|
+
const LEGACY_MACHINE_LOCK_FILE = ".start-lock";
|
|
111
|
+
const POLL_MS = 100;
|
|
112
|
+
/** A record that does not parse only counts as abandoned once it is this old. An older
|
|
113
|
+
* quickchr creates `.start-lock` empty and fills it in with a second syscall. */
|
|
114
|
+
const UNREADABLE_GRACE_MS = 500;
|
|
115
|
+
|
|
116
|
+
/** A machine's lock, inside its directory so it goes when the machine does. */
|
|
117
|
+
export function machineLockPath(machineDir: string): string {
|
|
118
|
+
return join(machineDir, ".lock");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where an older quickchr locks a machine. */
|
|
122
|
+
export function legacyMachineLockPath(machineDir: string): string {
|
|
123
|
+
return join(machineDir, LEGACY_MACHINE_LOCK_FILE);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Held across the read → probe → claim window of a machine creation, so two creates
|
|
127
|
+
* cannot pick the same port block or MAC (#140). Lives in the data dir root, not in
|
|
128
|
+
* `machines/`, where it would be listed as a machine. One fixed directory: its log
|
|
129
|
+
* collects down to two records. */
|
|
130
|
+
export function allocationLockPath(): string {
|
|
131
|
+
return join(getDataDir(), ".allocate.lock");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Test seam: called after each change this module makes inside a lock directory, so
|
|
135
|
+
* a test can interleave another contender at exactly that point. */
|
|
136
|
+
let mutationObserver: ((op: "link" | "unlink", path: string) => void) | undefined;
|
|
137
|
+
export function _observeLockMutations(fn: typeof mutationObserver): void {
|
|
138
|
+
mutationObserver = fn;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseRecord(text: string): LockRecord | undefined {
|
|
142
|
+
const trimmed = text.trim();
|
|
143
|
+
// A legacy `.start-lock` holds a bare pid.
|
|
144
|
+
if (/^\d+$/.test(trimmed)) {
|
|
145
|
+
return { pid: Number.parseInt(trimmed, 10), op: "start", at: "", token: "" };
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(trimmed) as Partial<LockRecord>;
|
|
149
|
+
if (typeof parsed.pid === "number" && parsed.pid > 0 && typeof parsed.op === "string") {
|
|
150
|
+
return parsed as LockRecord;
|
|
151
|
+
}
|
|
152
|
+
} catch { /* fall through */ }
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type Inspection =
|
|
157
|
+
| { state: "absent" }
|
|
158
|
+
| { state: "live"; record: LockRecord }
|
|
159
|
+
| { state: "free"; raw: string }
|
|
160
|
+
| { state: "unknown" };
|
|
161
|
+
|
|
162
|
+
function isAlive(pid: number): boolean {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, 0);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// EPERM: the process exists but belongs to someone else.
|
|
168
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readRaw(path: string): string | undefined {
|
|
173
|
+
try {
|
|
174
|
+
return readFileSync(path, "utf-8");
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One record file. `unknown` is a file whose holder cannot be checked: unreadable
|
|
181
|
+
* (EACCES from another user's lock, EISDIR, I/O), or unparseable and new enough that
|
|
182
|
+
* its writer may not have finished. */
|
|
183
|
+
function inspect(path: string): Inspection {
|
|
184
|
+
let raw: string;
|
|
185
|
+
try {
|
|
186
|
+
raw = readFileSync(path, "utf-8");
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return (e as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "unknown" };
|
|
189
|
+
}
|
|
190
|
+
const record = parseRecord(raw);
|
|
191
|
+
if (!record) {
|
|
192
|
+
let ageMs = Number.POSITIVE_INFINITY;
|
|
193
|
+
try { ageMs = Date.now() - statSync(path).mtimeMs; } catch { /* gone */ }
|
|
194
|
+
return ageMs > UNREADABLE_GRACE_MS ? { state: "free", raw } : { state: "unknown" };
|
|
195
|
+
}
|
|
196
|
+
if (record.released) return { state: "free", raw };
|
|
197
|
+
return isAlive(record.pid) ? { state: "live", record } : { state: "free", raw };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function missing(lockDir: string): QuickCHRError {
|
|
201
|
+
return new QuickCHRError("MACHINE_NOT_FOUND", `${dirname(lockDir)} no longer exists — the machine was removed.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The record numbers of one incarnation of a lock. A lock whose parent directory has
|
|
205
|
+
* gone belongs to a machine that was removed. */
|
|
206
|
+
function generations(lockDir: string, id: string): number[] {
|
|
207
|
+
const prefix = `${id}.`;
|
|
208
|
+
try {
|
|
209
|
+
return readdirSync(lockDir)
|
|
210
|
+
.filter((f) => f.startsWith(prefix) && /^\d+$/.test(f.slice(prefix.length)))
|
|
211
|
+
.map((f) => Number(f.slice(prefix.length)));
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(lockDir);
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function recordPath(lockDir: string, id: string, gen: number): string {
|
|
219
|
+
return join(lockDir, `${id}.${gen}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The lock directory's incarnation id, published once when the directory is first
|
|
223
|
+
* used, or undefined if it has none yet and `create` is false.
|
|
224
|
+
*
|
|
225
|
+
* Every record is named `<id>.<gen>`. A machine directory can be removed and created
|
|
226
|
+
* again under the same name, and a process paused across that still holds paths into
|
|
227
|
+
* the old lock. With the id in every name, those paths cannot reach the new lock's
|
|
228
|
+
* records: a collect, a rejected-claim cleanup or a released marker from a stale
|
|
229
|
+
* process touches only names nobody reads any more (Codex review of #190). */
|
|
230
|
+
function incarnation(lockDir: string, create: boolean): string | undefined {
|
|
231
|
+
const idPath = join(lockDir, "id");
|
|
232
|
+
const read = () => readRaw(idPath)?.trim() || undefined;
|
|
233
|
+
const existing = read();
|
|
234
|
+
if (existing || !create) return existing;
|
|
235
|
+
linkNew(idPath, crypto.randomUUID());
|
|
236
|
+
const id = read();
|
|
237
|
+
if (!id) throw missing(lockDir);
|
|
238
|
+
return id;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The lock directory, created if its parent exists. Never the parent: a waiter must
|
|
242
|
+
* not bring a removed machine's directory back as an empty orphan. */
|
|
243
|
+
function ensureLockDir(lockDir: string): void {
|
|
244
|
+
try {
|
|
245
|
+
mkdirSync(lockDir);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
248
|
+
if (code === "ENOENT") throw missing(lockDir);
|
|
249
|
+
if (code !== "EEXIST") throw e;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Publish `content` at `path` only if nothing is there. Written to a temp file and
|
|
254
|
+
* `link()`ed into place, so the file never exists half-written. */
|
|
255
|
+
function linkNew(path: string, content: string): boolean {
|
|
256
|
+
const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(tmp, content);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(dirname(path));
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
linkSync(tmp, path);
|
|
265
|
+
mutationObserver?.("link", path);
|
|
266
|
+
return true;
|
|
267
|
+
} catch (e) {
|
|
268
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
269
|
+
if (code === "EEXIST") return false;
|
|
270
|
+
if (code === "ENOENT") throw missing(dirname(path));
|
|
271
|
+
throw e;
|
|
272
|
+
} finally {
|
|
273
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Delete superseded records of one incarnation below `keepFrom`. Only a holder calls
|
|
278
|
+
* this, and only while it holds the lock: never the top, and never in a replacement
|
|
279
|
+
* directory, whose records carry another id. */
|
|
280
|
+
function collect(lockDir: string, id: string, keepFrom: number): void {
|
|
281
|
+
let gens: number[];
|
|
282
|
+
try { gens = generations(lockDir, id); } catch { return; }
|
|
283
|
+
for (const gen of gens) {
|
|
284
|
+
if (gen >= keepFrom) continue;
|
|
285
|
+
const path = recordPath(lockDir, id, gen);
|
|
286
|
+
try {
|
|
287
|
+
unlinkSync(path);
|
|
288
|
+
mutationObserver?.("unlink", path);
|
|
289
|
+
} catch { /* another collector got it */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type Top = { gen: number; seen: Inspection } | undefined;
|
|
294
|
+
|
|
295
|
+
/** The top record of an incarnation, or `"vanished"` when it went between listing and
|
|
296
|
+
* reading — a holder collected it — and the caller should look again. */
|
|
297
|
+
function readTop(lockDir: string, id: string): Top | "vanished" {
|
|
298
|
+
const gens = generations(lockDir, id);
|
|
299
|
+
if (gens.length === 0) return undefined;
|
|
300
|
+
const gen = Math.max(...gens);
|
|
301
|
+
const seen = inspect(recordPath(lockDir, id, gen));
|
|
302
|
+
return seen.state === "absent" ? "vanished" : { gen, seen };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Try to take incarnation `id` of the lock as record `gen`, having judged record
|
|
306
|
+
* `gen - 1` free with contents `judgedRaw` (or the incarnation empty, for `gen` 1 with
|
|
307
|
+
* no `judgedRaw`). Returns the lock, or undefined when another contender got there
|
|
308
|
+
* first or the judgement turned out to be stale.
|
|
309
|
+
*
|
|
310
|
+
* The claim stands only if, once created, it is the top, it follows exactly the
|
|
311
|
+
* record judged, and `id` is still the directory's incarnation. When `id` is still
|
|
312
|
+
* current after the first two checks, this record is the lock's live top, so nobody
|
|
313
|
+
* else holds it and nobody can remove the directory in between. */
|
|
314
|
+
export function claim(
|
|
315
|
+
lockDir: string,
|
|
316
|
+
id: string,
|
|
317
|
+
gen: number,
|
|
318
|
+
op: LockOperation,
|
|
319
|
+
judgedRaw?: string,
|
|
320
|
+
reserve?: ResourceReservation,
|
|
321
|
+
): HeldLock | undefined {
|
|
322
|
+
const record: LockRecord = {
|
|
323
|
+
pid: process.pid,
|
|
324
|
+
op,
|
|
325
|
+
at: new Date().toISOString(),
|
|
326
|
+
token: crypto.randomUUID(),
|
|
327
|
+
...(reserve ? { reserve } : {}),
|
|
328
|
+
};
|
|
329
|
+
const raw = `${JSON.stringify(record)}\n`;
|
|
330
|
+
const path = recordPath(lockDir, id, gen);
|
|
331
|
+
if (!linkNew(path, raw)) return undefined;
|
|
332
|
+
|
|
333
|
+
const gens = generations(lockDir, id);
|
|
334
|
+
const isTop = !gens.some((g) => g > gen);
|
|
335
|
+
const followsJudged = judgedRaw === undefined
|
|
336
|
+
? gen === 1 && gens.length === 1
|
|
337
|
+
: readRaw(recordPath(lockDir, id, gen - 1)) === judgedRaw;
|
|
338
|
+
if (!isTop || !followsJudged || incarnation(lockDir, false) !== id) {
|
|
339
|
+
// Our own name, in our incarnation: if the directory has been replaced since,
|
|
340
|
+
// this name means nothing in the new one.
|
|
341
|
+
try { unlinkSync(path); } catch { /* collected already, or the directory went */ }
|
|
342
|
+
mutationObserver?.("unlink", path);
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
collect(lockDir, id, gen);
|
|
346
|
+
return heldLock(lockDir, id, gen, record, raw);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function heldLock(lockDir: string, id: string, gen: number, record: LockRecord, raw: string): HeldLock {
|
|
350
|
+
let released = false;
|
|
351
|
+
return {
|
|
352
|
+
path: lockDir,
|
|
353
|
+
release() {
|
|
354
|
+
if (released) return;
|
|
355
|
+
released = true;
|
|
356
|
+
// Gone means this holder deleted the directory — remove(), or a failed create
|
|
357
|
+
// discarding what it made.
|
|
358
|
+
if (readRaw(recordPath(lockDir, id, gen)) !== raw) return;
|
|
359
|
+
// Everything destructive happens first, while this is still the holder. Once
|
|
360
|
+
// the marker is published another process can take the lock, remove the
|
|
361
|
+
// machine and let a same-named one start over, so nothing may be deleted by
|
|
362
|
+
// path after that point.
|
|
363
|
+
collect(lockDir, id, gen);
|
|
364
|
+
const marker: LockRecord = { ...record, released: true, at: new Date().toISOString() };
|
|
365
|
+
delete marker.reserve;
|
|
366
|
+
// EEXIST (a false return) means someone judged this holder dead and took over:
|
|
367
|
+
// nothing of ours is left to release. A missing directory means the same. Any
|
|
368
|
+
// other failure (ENOSPC, EACCES) means the marker was not written and the lock
|
|
369
|
+
// is still held, so it is thrown, and a later release() may try again.
|
|
370
|
+
try {
|
|
371
|
+
linkNew(recordPath(lockDir, id, gen + 1), `${JSON.stringify(marker)}\n`);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
if (e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") return;
|
|
374
|
+
released = false;
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function describeHolder(holder: LockRecord | undefined): string {
|
|
382
|
+
if (!holder) return "another operation (its lock record is unreadable or still being written)";
|
|
383
|
+
const age = holder.at ? Math.max(0, Math.round((Date.now() - Date.parse(holder.at)) / 1000)) : undefined;
|
|
384
|
+
return `\`${holder.op}\` (pid ${holder.pid}${age !== undefined && !Number.isNaN(age) ? `) since ${age}s` : ")"}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function lockedError(subject: string, holder: LockRecord | undefined, waitedMs: number): QuickCHRError {
|
|
388
|
+
const waited = waitedMs > 0 ? ` after waiting ${Math.round(waitedMs / 1000)}s` : "";
|
|
389
|
+
const hint = holder?.op === "foreground"
|
|
390
|
+
? " It is running in the foreground: quit it from that console (Ctrl-A X)."
|
|
391
|
+
: "";
|
|
392
|
+
return new QuickCHRError("MACHINE_LOCKED", `${subject} is locked by ${describeHolder(holder)}${waited}.${hint}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Whether a legacy `.start-lock` stands in the way, and its holder when that can be
|
|
396
|
+
* read. One that is unreadable, or so new its holder may still be writing it, blocks:
|
|
397
|
+
* going ahead in that window would run alongside an older quickchr. */
|
|
398
|
+
function legacyBlocker(legacyPath: string | undefined): { holder: LockRecord | undefined } | undefined {
|
|
399
|
+
if (!legacyPath) return undefined;
|
|
400
|
+
const seen = inspect(legacyPath);
|
|
401
|
+
if (seen.state === "live") return { holder: seen.record };
|
|
402
|
+
if (seen.state === "unknown") return { holder: undefined };
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** One attempt: the lock, or the holder in the way (undefined when its record cannot
|
|
407
|
+
* be read). */
|
|
408
|
+
function attempt(lockDir: string, op: LockOperation, opts: AcquireLockOptions): { lock: HeldLock } | { holder: LockRecord | undefined } {
|
|
409
|
+
ensureLockDir(lockDir);
|
|
410
|
+
for (;;) {
|
|
411
|
+
const id = incarnation(lockDir, true) as string;
|
|
412
|
+
const top = readTop(lockDir, id);
|
|
413
|
+
if (top === "vanished") continue;
|
|
414
|
+
if (top?.seen.state === "live") return { holder: top.seen.record };
|
|
415
|
+
if (top?.seen.state === "unknown") return { holder: undefined };
|
|
416
|
+
const judged = top?.seen.state === "free" ? top.seen.raw : undefined;
|
|
417
|
+
const lock = claim(lockDir, id, (top?.gen ?? 0) + 1, op, judged, opts.reserve);
|
|
418
|
+
if (!lock) continue;
|
|
419
|
+
const legacy = legacyBlocker(opts.legacyPath);
|
|
420
|
+
if (!legacy) return { lock };
|
|
421
|
+
lock.release();
|
|
422
|
+
return legacy;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Take a lock or fail now with `MACHINE_LOCKED`. A dead holder's lock is taken over
|
|
427
|
+
* on sight. Synchronous, for callers whose answer to a live holder is always "no" — a
|
|
428
|
+
* second `start` of one machine would only boot it twice. Throws `MACHINE_NOT_FOUND`
|
|
429
|
+
* if the lock's machine directory does not exist. */
|
|
430
|
+
export function acquireLockNow(lockPath: string, op: LockOperation, opts: Omit<AcquireLockOptions, "waitMs" | "onWait"> = {}): HeldLock {
|
|
431
|
+
const result = attempt(lockPath, op, opts);
|
|
432
|
+
if ("lock" in result) return result.lock;
|
|
433
|
+
throw lockedError(opts.subject ?? "Machine", result.holder, 0);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Take a lock, waiting up to `waitMs` for a live holder to release it. A dead holder's
|
|
437
|
+
* lock is taken over on sight; a `foreground` holder is never waited for, since it
|
|
438
|
+
* keeps the lock until its VM exits.
|
|
439
|
+
*
|
|
440
|
+
* Throws `MACHINE_LOCKED` naming the holder's operation, pid and age when the wait
|
|
441
|
+
* runs out, and `MACHINE_NOT_FOUND` if the machine is removed while this caller
|
|
442
|
+
* waits. */
|
|
443
|
+
export async function acquireLock(lockPath: string, op: LockOperation, opts: AcquireLockOptions = {}): Promise<HeldLock> {
|
|
444
|
+
const waitMs = opts.waitMs ?? 0;
|
|
445
|
+
const started = Date.now();
|
|
446
|
+
let notified = false;
|
|
447
|
+
for (;;) {
|
|
448
|
+
const result = attempt(lockPath, op, opts);
|
|
449
|
+
if ("lock" in result) return result.lock;
|
|
450
|
+
const { holder } = result;
|
|
451
|
+
const waited = Date.now() - started;
|
|
452
|
+
if (waited >= waitMs || holder?.op === "foreground") {
|
|
453
|
+
throw lockedError(opts.subject ?? "Machine", holder, waited);
|
|
454
|
+
}
|
|
455
|
+
if (!notified && holder) {
|
|
456
|
+
notified = true;
|
|
457
|
+
opts.onWait?.(holder);
|
|
458
|
+
}
|
|
459
|
+
await Bun.sleep(Math.min(POLL_MS, waitMs - waited));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The live holder of a lock, if any. */
|
|
464
|
+
export function currentHolder(lockPath: string): LockRecord | undefined {
|
|
465
|
+
for (;;) {
|
|
466
|
+
let top: Top | "vanished";
|
|
467
|
+
try {
|
|
468
|
+
const id = incarnation(lockPath, false);
|
|
469
|
+
if (!id) return undefined;
|
|
470
|
+
top = readTop(lockPath, id);
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined; // no lock directory, so no holder
|
|
473
|
+
}
|
|
474
|
+
if (top === "vanished") continue;
|
|
475
|
+
return top?.seen.state === "live" ? top.seen.record : undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Machines whose creation is in flight, with the port block and MACs each has
|
|
480
|
+
* claimed. Read from the creators' live machine locks; a crashed creator's
|
|
481
|
+
* reservation disappears with its pid. */
|
|
482
|
+
export function listReservations(exceptName?: string): Array<{ name: string } & ResourceReservation> {
|
|
483
|
+
const machinesDir = getMachinesDir();
|
|
484
|
+
let names: string[];
|
|
485
|
+
try {
|
|
486
|
+
names = readdirSync(machinesDir);
|
|
487
|
+
} catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
const out: Array<{ name: string } & ResourceReservation> = [];
|
|
491
|
+
for (const name of names) {
|
|
492
|
+
if (name === exceptName) continue;
|
|
493
|
+
const holder = currentHolder(machineLockPath(join(machinesDir, name)));
|
|
494
|
+
if (holder?.reserve) out.push({ name, ...holder.reserve });
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
package/src/lib/names.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-name validation — machine names and named-socket names.
|
|
3
|
+
*
|
|
4
|
+
* Both become a path segment under the data dir (`machines/<name>/`,
|
|
5
|
+
* `networks/<name>.json`), so a name is not free text: it has to be safe to join
|
|
6
|
+
* onto a path and safe to type back into a command. A name starting with `-`
|
|
7
|
+
* also reads as a flag everywhere it is echoed (`quickchr remove --help`), which
|
|
8
|
+
* is how `networks sockets create --help` persisted a socket called `--help` (#156).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { QuickCHRError } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Letters, digits, dot, underscore, hyphen — the charset already produced by
|
|
14
|
+
* `generateMachineName()` (e.g. `7.24.4-x86-1`), minus anything a shell or a
|
|
15
|
+
* path would reinterpret. */
|
|
16
|
+
const VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
17
|
+
|
|
18
|
+
const MAX_NAME_LENGTH = 64;
|
|
19
|
+
|
|
20
|
+
/** Throw `INVALID_NAME` unless `name` is safe as both a path segment and a CLI argument.
|
|
21
|
+
* `kind` names the resource in the message ("machine", "named socket"). */
|
|
22
|
+
export function assertValidResourceName(name: string, kind: string): void {
|
|
23
|
+
const reject = (why: string) => {
|
|
24
|
+
throw new QuickCHRError("INVALID_NAME", `Invalid ${kind} name "${name}" — ${why}`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (name.length === 0) reject("names cannot be empty");
|
|
28
|
+
if (name.startsWith("-")) reject(`names cannot start with "-" (it would be read as a flag)`);
|
|
29
|
+
if (name.length > MAX_NAME_LENGTH) reject(`names are limited to ${MAX_NAME_LENGTH} characters`);
|
|
30
|
+
if (!VALID_NAME.test(name)) {
|
|
31
|
+
reject("names may contain only letters, digits, dot, underscore and hyphen, and must start with a letter or digit");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The weaker rule that applies to a name being *used*, not created.
|
|
36
|
+
*
|
|
37
|
+
* `assertValidResourceName()` cannot guard a lookup or a delete: machines created
|
|
38
|
+
* under the older, looser rules have to stay addressable. But a name still becomes a
|
|
39
|
+
* path segment, and `join(machinesDir, name)` happily normalizes `..` into the data
|
|
40
|
+
* directory itself — so `removeOrphan("..")` would `rmSync` every machine, the cache
|
|
41
|
+
* and the socket registry. This is the minimum that stops that while passing every
|
|
42
|
+
* name a real machine can have. */
|
|
43
|
+
export function isPathSafeName(name: string): boolean {
|
|
44
|
+
if (name.length === 0) return false;
|
|
45
|
+
if (name === "." || name === "..") return false;
|
|
46
|
+
if (name.includes("/") || name.includes("\0")) return false;
|
|
47
|
+
// `\` is a path separator on Windows and an ordinary filename character on POSIX.
|
|
48
|
+
// Rejecting it everywhere would strand a machine named `lab\old` under the older,
|
|
49
|
+
// looser creation rules: `list` still shows it, so `remove` has to still clear it.
|
|
50
|
+
if (process.platform === "win32" && name.includes("\\")) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Throwing form of {@link isPathSafeName}, for the paths that delete. */
|
|
55
|
+
export function assertPathSafeName(name: string, kind: string): void {
|
|
56
|
+
if (!isPathSafeName(name)) {
|
|
57
|
+
throw new QuickCHRError(
|
|
58
|
+
"INVALID_NAME",
|
|
59
|
+
`Invalid ${kind} name ${JSON.stringify(name)} — a name cannot be empty, "." or "..", or contain a path separator`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Non-throwing form, for callers that want to branch rather than fail. */
|
|
65
|
+
export function isValidResourceName(name: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
assertValidResourceName(name, "resource");
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
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
|
+
}
|