pi-crew 0.9.62 → 0.9.64

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,610 @@
1
+ /**
2
+ * EngineManager — the host half of the evaluator (Node port, spike).
3
+ *
4
+ * Owns one persistent Node guest process, speaks the line-JSON protocol over a
5
+ * private pipe (fd 3), and exposes the execute / snapshot / restore API the
6
+ * spike needs to prove patterns 01 (namespace), 04 (transform), 05
7
+ * (incremental bindings), 08 (snapshot) and 09 (revive) on Node.
8
+ *
9
+ * SPIKE API DEVIATION from pi-rlm's engine:
10
+ * - the guest is spawned as `node --experimental-strip-types guest.ts`
11
+ * (NEVER `bun run`) — Node is the only runtime;
12
+ * - snapshotState(path) / restoreState(path) take an EXPLICIT file path and
13
+ * are called explicitly — NO debounce/scheduleSnapshot, NO
14
+ * options.snapshot config, NO hostHandlers (no host bridge in the spike);
15
+ * - EngineBusyError + maybeWedged liveness machinery dropped (minimalism);
16
+ * - everything else — sync queue-slot claim, output attribution, abort
17
+ * grace, truncateWithMarker, the childClosed race guard — is ported 1:1.
18
+ */
19
+
20
+ import { type ChildProcess, spawn } from "node:child_process";
21
+ import { randomUUID } from "node:crypto";
22
+ import { closeSync, constants as fsConstants, fstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
+ import { dirname } from "node:path";
24
+ import { createInterface } from "node:readline";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ /** D6 read-side cap (Checkpoint B, MINOR-2): restoreState refuses a file larger
28
+ * than this — an independent guard from the lifecycle cap (same value, no
29
+ * import to keep engine free of a lifecycle cycle). Bounds v8.deserialize
30
+ * amplification from a swapped snapshot file. */
31
+ const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
32
+
33
+ import { decodeMessage, encodeMessage, type GuestToHostMessage, type HostToGuestMessage, NONCE_ENV, PROTOCOL_FD } from "./protocol.ts";
34
+
35
+ const GUEST_PATH = fileURLToPath(new URL("./guest.ts", import.meta.url));
36
+ const DEFAULT_MAX_OUTPUT_CHARS = 65536;
37
+ const READY_TIMEOUT_MS = 10_000;
38
+ const ABORT_GRACE_MS = 500;
39
+ const PING_TIMEOUT_MS = 5_000;
40
+ const SNAPSHOT_REQUEST_TIMEOUT_MS = 30_000;
41
+
42
+ export interface EngineExecuteError {
43
+ /** Error class name, e.g. "TypeError". */
44
+ name: string;
45
+ message: string;
46
+ /** Stack trace, split into lines. */
47
+ stack: string[];
48
+ }
49
+
50
+ export interface ExecuteResult {
51
+ stdout: string;
52
+ stderr: string;
53
+ /** Rendered value of the cell's final expression, when it has one. */
54
+ result?: string;
55
+ status: "ok" | "error" | "aborted";
56
+ error?: EngineExecuteError;
57
+ durationMs: number;
58
+ }
59
+
60
+ export interface ExecuteOptions {
61
+ /** Aborting cancels the cell cooperatively; namespace is preserved. */
62
+ signal?: AbortSignal;
63
+ onStream?: (chunk: string, name: "stdout" | "stderr") => void;
64
+ /** Cap stdout / stderr / result at this many characters. Default 65536. */
65
+ maxOutputChars?: number;
66
+ }
67
+
68
+ /** Lifecycle state of an EngineManager. "shutdown" is terminal. */
69
+ export type EngineState = "idle" | "starting" | "running" | "shutdown";
70
+
71
+ export interface SnapshotResult {
72
+ path: string;
73
+ /** Top-level names successfully serialized. */
74
+ saved: string[];
75
+ /** Names that could not be serialized, with reasons. */
76
+ failed: { name: string; reason: string }[];
77
+ }
78
+
79
+ export interface RestoreResult {
80
+ path: string;
81
+ restored: string[];
82
+ failed: { name: string; reason: string }[];
83
+ }
84
+
85
+ export interface EngineOptions {
86
+ cwd?: string;
87
+ env?: Record<string, string>;
88
+ }
89
+
90
+ interface ActiveExecution {
91
+ cellId: string;
92
+ started: number;
93
+ maxChars: number;
94
+ opts: ExecuteOptions;
95
+ stdout: string;
96
+ stderr: string;
97
+ stdoutTruncated: boolean;
98
+ stderrTruncated: boolean;
99
+ result?: string;
100
+ error?: EngineExecuteError;
101
+ status: ExecuteResult["status"];
102
+ settled: boolean;
103
+ /** Set on cancellation: a cancelled cell must stop contributing output at once. */
104
+ abortRequested: boolean;
105
+ resolve(result: ExecuteResult): void;
106
+ reject(error: Error): void;
107
+ }
108
+
109
+ // ── process-wide cleanup ─────────────────────────────────────────────────────
110
+ // Guests are killed when the host exits normally. As a backstop the guest also
111
+ // self-exits when its stdin reaches EOF, which covers a host death abrupt
112
+ // enough that no handler runs.
113
+
114
+ const liveEngines = new Set<EngineManager>();
115
+ let cleanupHandlersInstalled = false;
116
+
117
+ function installProcessCleanupOnce(): void {
118
+ if (cleanupHandlersInstalled) return;
119
+ cleanupHandlersInstalled = true;
120
+ process.on("exit", () => {
121
+ for (const engine of liveEngines) engine.killSync();
122
+ });
123
+ }
124
+
125
+ interface PendingRequest {
126
+ resolve(message: GuestToHostMessage): void;
127
+ reject(error: Error): void;
128
+ timer?: ReturnType<typeof setTimeout>;
129
+ }
130
+
131
+ function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolean): string {
132
+ if (!wasTruncated && text.length <= maxChars) return text;
133
+ return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
134
+ }
135
+
136
+ export class EngineManager {
137
+ private readonly options: EngineOptions;
138
+ private child?: ChildProcess;
139
+ private engineState: EngineState = "idle";
140
+ private startPromise?: Promise<void>;
141
+ private executionQueue: Promise<unknown> = Promise.resolve();
142
+ private activeExecution?: ActiveExecution;
143
+ private readonly pendingRequests = new Map<string, PendingRequest>();
144
+ /** Per-process protocol nonce; also names the guest's internal bindings. */
145
+ private readonly nonce = randomUUID().replaceAll("-", "");
146
+ /** Tail of the guest's own stderr, surfaced when it dies unexpectedly. */
147
+ private guestStderr = "";
148
+ /** Resolves when the child and all of its stdio have fully closed. */
149
+ private childClosed?: Promise<void>;
150
+ /** Held so the protocol reader is not garbage-collected mid-session, which
151
+ * would close the guest's write end and kill it with EPIPE. */
152
+ private protocolReader?: ReturnType<typeof createInterface>;
153
+
154
+ constructor(options: EngineOptions = {}) {
155
+ this.options = options;
156
+ }
157
+
158
+ get isRunning(): boolean {
159
+ return this.engineState === "running";
160
+ }
161
+
162
+ /**
163
+ * Public read of the lifecycle state (F12): lets callers distinguish a
164
+ * TERMINAL shutdown engine (start() will throw "Engine has been shut down")
165
+ * from a wedged-but-alive one. The private field is `engineState` so this
166
+ * accessor can share the `state` name.
167
+ */
168
+ get state(): EngineState {
169
+ return this.engineState;
170
+ }
171
+
172
+ // ── lifecycle ──────────────────────────────────────────────────────────────
173
+
174
+ async start(): Promise<void> {
175
+ if (this.engineState === "shutdown") throw new Error("Engine has been shut down");
176
+ if (!this.startPromise) {
177
+ const startup = this.doStart().catch((error) => {
178
+ this.startPromise = undefined;
179
+ throw error;
180
+ });
181
+ // Callers await the rejection; this guard keeps a startup failure that
182
+ // nobody is waiting on from surfacing as an unhandled rejection.
183
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional fire-and-forget guard.
184
+ startup.catch(() => {});
185
+ this.startPromise = startup;
186
+ }
187
+ return this.startPromise;
188
+ }
189
+
190
+ private async doStart(): Promise<void> {
191
+ this.engineState = "starting";
192
+ installProcessCleanupOnce();
193
+ liveEngines.add(this);
194
+ const child = spawn(process.execPath, ["--experimental-strip-types", GUEST_PATH], {
195
+ cwd: this.options.cwd,
196
+ env: {
197
+ ...process.env,
198
+ ...(this.options.env ?? {}),
199
+ [NONCE_ENV]: this.nonce,
200
+ },
201
+ // fd 3 carries protocol traffic so stdout/stderr stay pure user output.
202
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
203
+ });
204
+ this.child = child;
205
+ this.childClosed = new Promise((resolve) => child.once("close", () => resolve()));
206
+
207
+ const ready = new Promise<void>((resolve, reject) => {
208
+ const timer = setTimeout(() => reject(new Error("Engine guest did not become ready in time")), READY_TIMEOUT_MS);
209
+ timer.unref?.();
210
+ this.pendingRequests.set("__ready__", {
211
+ resolve: () => {
212
+ clearTimeout(timer);
213
+ resolve();
214
+ },
215
+ reject: (error) => {
216
+ clearTimeout(timer);
217
+ reject(error);
218
+ },
219
+ });
220
+ });
221
+
222
+ const protocolStream = child.stdio[PROTOCOL_FD] as NodeJS.ReadableStream | null;
223
+ if (!protocolStream) {
224
+ throw new Error("Engine guest was spawned without a protocol pipe on fd 3");
225
+ }
226
+ this.protocolReader = createInterface({ input: protocolStream });
227
+ this.protocolReader.on("line", (line) => this.handleGuestLine(line));
228
+ // Anything the guest writes to the real stdout/stderr fds is subprocess
229
+ // output; attribute it to the running cell.
230
+ child.stdout!.on("data", (buffer: Buffer) => this.appendActiveOutput("stdout", buffer.toString()));
231
+ child.stderr!.on("data", (buffer: Buffer) => {
232
+ const text = buffer.toString();
233
+ this.guestStderr = (this.guestStderr + text).slice(-4000);
234
+ this.appendActiveOutput("stderr", text);
235
+ });
236
+
237
+ child.on("error", (error) => {
238
+ const message = `Engine process failed: ${error.message}`;
239
+ this.failAllPending(new Error(message));
240
+ this.transitionToShutdown(message);
241
+ });
242
+ child.on("exit", (code, signal) => {
243
+ // A killed child's exit event arrives after teardown has already moved
244
+ // on. Acting on it would reject an execution nobody is waiting for any
245
+ // more, surfacing as an unhandled rejection in an unrelated context.
246
+ if (this.child !== child) return;
247
+ if (this.engineState !== "shutdown") {
248
+ const tail = this.guestStderr.trim();
249
+ const reason =
250
+ `Engine process exited unexpectedly (code=${code} signal=${signal})` +
251
+ (tail ? `\nguest stderr:\n${tail.slice(-1500)}` : "");
252
+ this.failAllPending(new Error(reason));
253
+ this.transitionToShutdown(reason);
254
+ }
255
+ });
256
+
257
+ await ready;
258
+ // Being torn down while starting wins: without this the late assignment
259
+ // resurrects a killed engine as "running", and the child's own exit event
260
+ // then reads that as an unexpected death.
261
+ if ((this.engineState as string) === "shutdown") throw new Error("Engine has been shut down");
262
+ this.engineState = "running";
263
+ }
264
+
265
+ private transitionToShutdown(reason: string): void {
266
+ this.engineState = "shutdown";
267
+ const active = this.activeExecution;
268
+ if (active && !active.settled) {
269
+ this.activeExecution = undefined;
270
+ active.settled = true;
271
+ active.reject(new Error(reason));
272
+ }
273
+ }
274
+
275
+ private failAllPending(error: Error): void {
276
+ for (const [, pending] of this.pendingRequests) {
277
+ if (pending.timer) clearTimeout(pending.timer);
278
+ pending.reject(error);
279
+ }
280
+ this.pendingRequests.clear();
281
+ }
282
+
283
+ async kill(): Promise<void> {
284
+ const closed = this.childClosed;
285
+ this.killSync();
286
+ // Teardown is not done until the child's stdio is actually closed. A
287
+ // SIGKILL'd child's pipes are torn down asynchronously, and a spawn that
288
+ // follows too quickly recycles those descriptors while the teardown is
289
+ // still in flight — which can close a pipe belonging to the new engine.
290
+ // Observed as a fresh guest hitting EPIPE on its first protocol write.
291
+ if (closed) {
292
+ await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref?.())]);
293
+ }
294
+ }
295
+
296
+ /** Synchronous teardown, safe from process.on("exit"). */
297
+ killSync(): void {
298
+ const active = this.activeExecution;
299
+ if (active && !active.settled) {
300
+ active.status = "aborted";
301
+ this.settleActiveExecution(active);
302
+ }
303
+ this.engineState = "shutdown";
304
+ liveEngines.delete(this);
305
+ this.failAllPending(new Error("Engine has been shut down"));
306
+ this.child?.kill("SIGKILL");
307
+ this.child = undefined;
308
+ this.protocolReader?.close();
309
+ this.protocolReader = undefined;
310
+ }
311
+
312
+ /**
313
+ * Graceful cleanup: optionally flush a final snapshot (explicit path, since
314
+ * the spike has no auto-snapshot config), then terminate the guest.
315
+ */
316
+ async dispose(snapshotPath?: string): Promise<void> {
317
+ if (snapshotPath && this.engineState === "running") {
318
+ await this.snapshotState(snapshotPath).catch(() => null);
319
+ }
320
+ await this.kill();
321
+ }
322
+
323
+ // ── guest messaging ────────────────────────────────────────────────────────
324
+
325
+ private sendToGuest(message: HostToGuestMessage): void {
326
+ // A write into a dying child's stdin can throw synchronously. A dead pipe
327
+ // here only ever means "engine gone", which every caller already learns
328
+ // through the exit path — a late write must not become an unhandled
329
+ // rejection.
330
+ try {
331
+ this.child?.stdin?.write(encodeMessage(message, this.nonce));
332
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: a dead pipe only means engine gone; every caller learns via the exit path.
333
+ } catch {}
334
+ }
335
+
336
+ private request(message: HostToGuestMessage & { id: string }, timeoutMs: number): Promise<GuestToHostMessage> {
337
+ const pending = new Promise<GuestToHostMessage>((resolve, reject) => {
338
+ const timer = setTimeout(() => {
339
+ this.pendingRequests.delete(message.id);
340
+ reject(new Error(`Engine request ${message.type} timed out`));
341
+ }, timeoutMs);
342
+ timer.unref?.();
343
+ this.pendingRequests.set(message.id, { resolve, reject, timer });
344
+ this.sendToGuest(message);
345
+ });
346
+ // Teardown rejects every outstanding request. A caller that has already
347
+ // moved on is no longer listening, and that rejection would otherwise
348
+ // escape as an unhandled rejection in whatever happens to be running.
349
+ // Marking it handled here does not hide anything from the real caller,
350
+ // which still receives the rejection through the returned promise.
351
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op — the returned promise still carries the rejection.
352
+ pending.catch(() => {});
353
+ return pending;
354
+ }
355
+
356
+ private handleGuestLine(line: string): void {
357
+ // fd 3 carries only protocol traffic; a line that fails to decode (wrong
358
+ // nonce, malformed) is discarded rather than shown as output.
359
+ const message = decodeMessage<GuestToHostMessage>(line, this.nonce);
360
+ if (!message) return;
361
+ switch (message.type) {
362
+ case "ready": {
363
+ const pending = this.pendingRequests.get("__ready__");
364
+ if (pending) {
365
+ this.pendingRequests.delete("__ready__");
366
+ pending.resolve(message);
367
+ }
368
+ break;
369
+ }
370
+ case "stream": {
371
+ const active = this.activeExecution;
372
+ // Untagged output belongs to no cell; attributing it to whichever cell
373
+ // is active is the same class of bug as the orphan leak.
374
+ if (!active || active.settled || message.cellId !== active.cellId) return;
375
+ this.appendOutput(active, message.name, message.chunk);
376
+ break;
377
+ }
378
+ case "done": {
379
+ const active = this.activeExecution;
380
+ if (!active || active.settled || active.cellId !== message.cellId) return;
381
+ if (message.status === "error") {
382
+ active.status = "error";
383
+ active.error = message.error;
384
+ } else if (message.status === "aborted") {
385
+ active.status = "aborted";
386
+ } else {
387
+ active.result = message.result;
388
+ }
389
+ this.settleActiveExecution(active);
390
+ break;
391
+ }
392
+ case "pong": {
393
+ this.resolveRequest(message.id, message);
394
+ break;
395
+ }
396
+ case "snapshot_result":
397
+ case "restore_result":
398
+ case "names_result": {
399
+ this.resolveRequest(message.id, message);
400
+ break;
401
+ }
402
+ }
403
+ }
404
+
405
+ private resolveRequest(id: string, message: GuestToHostMessage): void {
406
+ const pending = this.pendingRequests.get(id);
407
+ if (!pending) return;
408
+ this.pendingRequests.delete(id);
409
+ if (pending.timer) clearTimeout(pending.timer);
410
+ pending.resolve(message);
411
+ }
412
+
413
+ // ── output accumulation ────────────────────────────────────────────────────
414
+
415
+ private appendActiveOutput(name: "stdout" | "stderr", text: string): void {
416
+ const active = this.activeExecution;
417
+ if (!active || active.settled) return;
418
+ this.appendOutput(active, name, text);
419
+ }
420
+
421
+ private appendOutput(active: ActiveExecution, name: "stdout" | "stderr", text: string): void {
422
+ if (active.abortRequested) return;
423
+ const key = name === "stdout" ? "stdout" : "stderr";
424
+ const truncatedKey = name === "stdout" ? "stdoutTruncated" : "stderrTruncated";
425
+ if (active[key].length < active.maxChars) {
426
+ active[key] += text;
427
+ if (active[key].length > active.maxChars) {
428
+ active[key] = active[key].slice(0, active.maxChars);
429
+ active[truncatedKey] = true;
430
+ }
431
+ } else {
432
+ active[truncatedKey] = true;
433
+ }
434
+ active.opts.onStream?.(text, name);
435
+ }
436
+
437
+ // ── execute ────────────────────────────────────────────────────────────────
438
+
439
+ async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
440
+ // Claim the queue slot synchronously, before the first await, so that
441
+ // submission order is execution order for concurrent callers.
442
+ const previous = this.executionQueue;
443
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: release is assigned synchronously below before any await.
444
+ let release: () => void = () => {};
445
+ this.executionQueue = new Promise<void>((resolve) => {
446
+ release = resolve;
447
+ });
448
+ await previous;
449
+
450
+ try {
451
+ if (opts.signal?.aborted) {
452
+ return { stdout: "", stderr: "", status: "aborted", durationMs: 0 };
453
+ }
454
+ if (this.engineState === "shutdown") {
455
+ throw new Error("Engine has been shut down");
456
+ }
457
+ await this.start();
458
+ if ((this.engineState as string) === "shutdown") {
459
+ throw new Error("Engine has been shut down");
460
+ }
461
+ return await this.executeInner(code, opts);
462
+ } finally {
463
+ release();
464
+ }
465
+ }
466
+
467
+ private executeInner(code: string, opts: ExecuteOptions): Promise<ExecuteResult> {
468
+ const cellId = randomUUID();
469
+ const started = Date.now();
470
+
471
+ return new Promise<ExecuteResult>((resolve, reject) => {
472
+ const active: ActiveExecution = {
473
+ cellId,
474
+ started,
475
+ maxChars: opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS,
476
+ opts,
477
+ stdout: "",
478
+ stderr: "",
479
+ stdoutTruncated: false,
480
+ stderrTruncated: false,
481
+ status: "ok",
482
+ settled: false,
483
+ abortRequested: false,
484
+ resolve,
485
+ reject,
486
+ };
487
+ this.activeExecution = active;
488
+
489
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
490
+ const onAbort = () => {
491
+ active.abortRequested = true;
492
+ this.sendToGuest({ type: "abort", cellId });
493
+ graceTimer = setTimeout(() => {
494
+ if (this.activeExecution === active && !active.settled) {
495
+ active.status = "aborted";
496
+ this.settleActiveExecution(active);
497
+ }
498
+ }, ABORT_GRACE_MS);
499
+ graceTimer.unref?.();
500
+ };
501
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
502
+
503
+ const originalResolve = active.resolve;
504
+ active.resolve = (result) => {
505
+ opts.signal?.removeEventListener("abort", onAbort);
506
+ if (graceTimer) clearTimeout(graceTimer);
507
+ originalResolve(result);
508
+ };
509
+ const originalReject = active.reject;
510
+ active.reject = (error) => {
511
+ opts.signal?.removeEventListener("abort", onAbort);
512
+ if (graceTimer) clearTimeout(graceTimer);
513
+ originalReject(error);
514
+ };
515
+
516
+ this.sendToGuest({ type: "run", cellId, code });
517
+ });
518
+ }
519
+
520
+ private settleActiveExecution(active: ActiveExecution): void {
521
+ if (active.settled) return;
522
+ active.settled = true;
523
+ if (this.activeExecution === active) this.activeExecution = undefined;
524
+
525
+ // A cancelled cell reports "aborted" even if it happened to finish first:
526
+ // the caller withdrew interest, so the value is not theirs to consume.
527
+ let status = active.status;
528
+ if (active.opts.signal?.aborted) status = "aborted";
529
+
530
+ const stdout = truncateWithMarker(active.stdout, active.maxChars, active.stdoutTruncated);
531
+ const stderr = truncateWithMarker(active.stderr, active.maxChars, active.stderrTruncated);
532
+ let result = active.result;
533
+ if (result !== undefined && result.length > active.maxChars) {
534
+ result = truncateWithMarker(result, active.maxChars, true);
535
+ }
536
+
537
+ active.resolve({
538
+ stdout,
539
+ stderr,
540
+ result,
541
+ error: active.error,
542
+ status,
543
+ durationMs: Date.now() - active.started,
544
+ });
545
+ }
546
+
547
+ // ── snapshot / restore / names ─────────────────────────────────────────────
548
+ // SPIKE: explicit file paths; no debounce, no options.snapshot config.
549
+
550
+ async snapshotState(path: string): Promise<SnapshotResult | null> {
551
+ if (this.engineState !== "running") return null;
552
+ try {
553
+ const reply = await this.request({ type: "snapshot", id: randomUUID() }, SNAPSHOT_REQUEST_TIMEOUT_MS);
554
+ if (reply.type !== "snapshot_result") return null;
555
+ mkdirSync(dirname(path), { recursive: true });
556
+ // Phase 3 (D2'): atomic write — temp + rename same-dir eliminates the
557
+ // torn-write race (debounce timer vs F3 quit, or any future 2nd writer).
558
+ // Same directory ⇒ same filesystem ⇒ rename is atomic.
559
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
560
+ writeFileSync(tmp, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
561
+ renameSync(tmp, path);
562
+ return { path, saved: Object.keys(reply.vars), failed: reply.failed };
563
+ } catch {
564
+ return null;
565
+ }
566
+ }
567
+
568
+ async restoreState(path: string): Promise<RestoreResult | null> {
569
+ // MINOR-2 (Checkpoint B review) + holistic MINOR-1 (fd leak): open ONCE with
570
+ // O_NOFOLLOW, fstat for regular-file + size cap, read via that fd — ALL
571
+ // under a single try/finally so the fd is closed on EVERY path (the early
572
+ // `return null` checks and an `await this.start()` throw included). Closes
573
+ // the TOCTOU between the caller's lstat and this read, and the cap is
574
+ // enforced HERE so a caller cannot bypass it.
575
+ let fd: number | undefined;
576
+ try {
577
+ try {
578
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
579
+ } catch {
580
+ return null; // missing / symlinked final component — fail-open
581
+ }
582
+ const st = fstatSync(fd);
583
+ if (!st.isFile() || st.isSymbolicLink()) return null;
584
+ if (st.size > SNAPSHOT_MAX_BYTES) return null;
585
+ // file validated → lazily start the engine, then read+restore via fd.
586
+ await this.start();
587
+ const payload = JSON.parse(readFileSync(fd, { encoding: "utf8" })) as {
588
+ vars?: Record<string, string>;
589
+ };
590
+ const vars = payload.vars ?? {};
591
+ const reply = await this.request({ type: "restore", id: randomUUID(), vars }, SNAPSHOT_REQUEST_TIMEOUT_MS);
592
+ if (reply.type !== "restore_result") return null;
593
+ return { path, restored: reply.restored, failed: reply.failed };
594
+ } catch {
595
+ return null;
596
+ } finally {
597
+ if (fd !== undefined) closeSync(fd);
598
+ }
599
+ }
600
+
601
+ async listNamespaceNames(): Promise<string[] | null> {
602
+ if (this.engineState !== "running") return null;
603
+ try {
604
+ const reply = await this.request({ type: "list_names", id: randomUUID() }, PING_TIMEOUT_MS);
605
+ return reply.type === "names_result" ? reply.names : null;
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
610
+ }