pi-repl-py 0.1.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/ARCHITECTURE.md +141 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/docs/how-to-functions.md +107 -0
- package/docs/philosophy.md +88 -0
- package/index.ts +220 -0
- package/package.json +57 -0
- package/scripts/setup-venv.mjs +71 -0
- package/src/engine/guest.py +317 -0
- package/src/engine/index.ts +656 -0
- package/src/engine/protocol.ts +66 -0
- package/src/engine/toolbox/bash.py +72 -0
- package/src/engine/toolbox/edit.py +37 -0
- package/src/engine/toolbox/read.py +26 -0
- package/src/engine/toolbox/write.py +23 -0
- package/src/extension/config.ts +65 -0
- package/src/extension/preview-core.ts +518 -0
- package/src/extension/render-core.ts +348 -0
- package/src/extension/render.ts +93 -0
- package/src/extension/session-engine.ts +155 -0
- package/src/extension/tool-meta.ts +58 -0
- package/src/extension/toolbox.ts +74 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
// --- EngineManager: the host half; one python3 guest over a private fd3 line-JSON pipe ---
|
|
2
|
+
|
|
3
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import {
|
|
11
|
+
decodeMessage,
|
|
12
|
+
encodeMessage,
|
|
13
|
+
type GuestToHostMessage,
|
|
14
|
+
type HostToGuestMessage,
|
|
15
|
+
NONCE_ENV,
|
|
16
|
+
PROTOCOL_FD,
|
|
17
|
+
} from "./protocol.js";
|
|
18
|
+
|
|
19
|
+
const GUEST_PATH = fileURLToPath(new URL("./guest.py", import.meta.url));
|
|
20
|
+
|
|
21
|
+
// --- venv created by the package postinstall ---
|
|
22
|
+
function installVenvPython(): string {
|
|
23
|
+
return join(homedir(), ".pi", "agent", "pi-repl-venv", "bin", "python3");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- prefer a venv with ipykernel; else PYTHON or python3 ---
|
|
27
|
+
function resolvePythonPath(cwd: string | undefined): string {
|
|
28
|
+
const repoVenv = join(dirname(GUEST_PATH), "..", "..", ".venv", "bin", "python3");
|
|
29
|
+
if (existsSync(repoVenv)) return repoVenv;
|
|
30
|
+
const cwdVenv = cwd ? join(cwd, ".venv", "bin", "python3") : "";
|
|
31
|
+
if (cwdVenv && existsSync(cwdVenv)) return cwdVenv;
|
|
32
|
+
const installVenv = installVenvPython();
|
|
33
|
+
if (existsSync(installVenv)) return installVenv;
|
|
34
|
+
return process.env.PYTHON ?? "python3";
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 65536;
|
|
37
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
38
|
+
const ABORT_GRACE_MS = 500;
|
|
39
|
+
const PING_TIMEOUT_MS = 5_000;
|
|
40
|
+
const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500;
|
|
41
|
+
const SNAPSHOT_REQUEST_TIMEOUT_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
interface EngineExecuteError {
|
|
44
|
+
/** Error class name, e.g. "TypeError". */
|
|
45
|
+
name: string;
|
|
46
|
+
message: string;
|
|
47
|
+
/** Stack trace, split into lines. */
|
|
48
|
+
stack: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ExecuteResult {
|
|
52
|
+
stdout: string;
|
|
53
|
+
stderr: string;
|
|
54
|
+
/** Rendered value of the cell's final expression, when it has one. */
|
|
55
|
+
result?: string;
|
|
56
|
+
status: "ok" | "error" | "aborted";
|
|
57
|
+
error?: EngineExecuteError;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ExecuteOptions {
|
|
62
|
+
/** Aborting cancels the cell cooperatively; namespace is preserved. */
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
65
|
+
/** Cap stdout / stderr / result at this many characters. Default 65536. */
|
|
66
|
+
maxOutputChars?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SnapshotResult {
|
|
70
|
+
path: string;
|
|
71
|
+
/** Top-level names successfully serialized. */
|
|
72
|
+
saved: string[];
|
|
73
|
+
/** Names that could not be serialized, with reasons. */
|
|
74
|
+
failed: { name: string; reason: string }[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RestoreResult {
|
|
78
|
+
path: string;
|
|
79
|
+
restored: string[];
|
|
80
|
+
failed: { name: string; reason: string }[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EngineOptions {
|
|
84
|
+
cwd?: string;
|
|
85
|
+
/** Python interpreter to spawn the guest with. Defaults to the repo venv. */
|
|
86
|
+
pythonPath?: string;
|
|
87
|
+
/** Directory of toolbox functions to exec into the kernel (PI_TOOLBOX_DIR). */
|
|
88
|
+
toolboxDir?: string;
|
|
89
|
+
/** Per-cell response timeout, ms. 0 = no cap; nonzero = silence watchdog. */
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
env?: Record<string, string>;
|
|
92
|
+
/** Persist/revive the namespace across engine restarts. */
|
|
93
|
+
snapshot?: {
|
|
94
|
+
path: string;
|
|
95
|
+
/** Debounce for the auto-snapshot after each ok cell. Default 1500 ms. */
|
|
96
|
+
debounceMs?: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Thrown when a cancelled cell is still occupying the evaluator. Cancellation is
|
|
102
|
+
* cooperative; the caller recovers by killing the engine and restoring.
|
|
103
|
+
*/
|
|
104
|
+
export class EngineBusyError extends Error {
|
|
105
|
+
constructor() {
|
|
106
|
+
super("Engine is still running the previously interrupted cell. Kill the engine to start fresh.");
|
|
107
|
+
this.name = "EngineBusyError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ActiveExecution {
|
|
112
|
+
cellId: string;
|
|
113
|
+
code: string;
|
|
114
|
+
started: number;
|
|
115
|
+
maxChars: number;
|
|
116
|
+
opts: ExecuteOptions;
|
|
117
|
+
stdout: string;
|
|
118
|
+
stderr: string;
|
|
119
|
+
stdoutTruncated: boolean;
|
|
120
|
+
stderrTruncated: boolean;
|
|
121
|
+
result?: string;
|
|
122
|
+
error?: EngineExecuteError;
|
|
123
|
+
status: ExecuteResult["status"];
|
|
124
|
+
settled: boolean;
|
|
125
|
+
/** Set on cancellation: a cancelled cell must stop contributing output at once. */
|
|
126
|
+
abortRequested: boolean;
|
|
127
|
+
/** Cumulative chars forwarded to onStream; capped so the live view can't grow unbounded. */
|
|
128
|
+
streamedChars: number;
|
|
129
|
+
/**
|
|
130
|
+
* Aborts host-side work done on this cell's behalf.
|
|
131
|
+
*/
|
|
132
|
+
hostAbort: AbortController;
|
|
133
|
+
resolve(result: ExecuteResult): void;
|
|
134
|
+
reject(error: Error): void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── process-wide cleanup ─────────────────────────────────────────────────────
|
|
138
|
+
// Guests are killed on host exit; the guest also self-exits on stdin EOF.
|
|
139
|
+
|
|
140
|
+
const liveEngines = new Set<EngineManager>();
|
|
141
|
+
let cleanupHandlersInstalled = false;
|
|
142
|
+
|
|
143
|
+
function installProcessCleanupOnce(): void {
|
|
144
|
+
if (cleanupHandlersInstalled) return;
|
|
145
|
+
cleanupHandlersInstalled = true;
|
|
146
|
+
process.on("exit", () => {
|
|
147
|
+
for (const engine of liveEngines) engine.killSync();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface PendingRequest {
|
|
152
|
+
resolve(message: GuestToHostMessage): void;
|
|
153
|
+
reject(error: Error): void;
|
|
154
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolean): string {
|
|
158
|
+
if (!wasTruncated && text.length <= maxChars) return text;
|
|
159
|
+
return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export class EngineManager {
|
|
163
|
+
private readonly options: EngineOptions;
|
|
164
|
+
private readonly pythonPath: string;
|
|
165
|
+
private readonly toolboxDir?: string;
|
|
166
|
+
private readonly timeoutMs: number;
|
|
167
|
+
private child?: ChildProcess;
|
|
168
|
+
private state: "idle" | "starting" | "running" | "shutdown" = "idle";
|
|
169
|
+
private startPromise?: Promise<void>;
|
|
170
|
+
private executionQueue: Promise<unknown> = Promise.resolve();
|
|
171
|
+
private activeExecution?: ActiveExecution;
|
|
172
|
+
private readonly pendingRequests = new Map<string, PendingRequest>();
|
|
173
|
+
private readonly nonce = randomUUID().replaceAll("-", ""); // --- per-process protocol nonce ---
|
|
174
|
+
private guestStderr = ""; // --- tail of the guest's stderr, for unexpected-death reports ---
|
|
175
|
+
private childClosed?: Promise<void>;
|
|
176
|
+
/** Held so the protocol reader is not garbage-collected mid-session, which
|
|
177
|
+
* would close the guest's write end and kill it with EPIPE. */
|
|
178
|
+
private protocolReader?: ReturnType<typeof createInterface>;
|
|
179
|
+
private maybeWedged = false;
|
|
180
|
+
private snapshotTimer?: ReturnType<typeof setTimeout>;
|
|
181
|
+
|
|
182
|
+
constructor(options: EngineOptions = {}) {
|
|
183
|
+
this.options = options;
|
|
184
|
+
this.pythonPath = options.pythonPath ?? resolvePythonPath(options.cwd);
|
|
185
|
+
this.toolboxDir = options.toolboxDir;
|
|
186
|
+
this.timeoutMs = options.timeoutMs ?? 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get isRunning(): boolean {
|
|
190
|
+
return this.state === "running";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── lifecycle ──────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
async start(): Promise<void> {
|
|
196
|
+
if (this.state === "shutdown") throw new Error("Engine has been shut down");
|
|
197
|
+
if (!this.startPromise) {
|
|
198
|
+
const startup = this.doStart().catch((error) => {
|
|
199
|
+
this.startPromise = undefined;
|
|
200
|
+
throw error;
|
|
201
|
+
});
|
|
202
|
+
// --- keep a startup failure nobody awaits from surfacing as unhandled ---
|
|
203
|
+
startup.catch(() => {});
|
|
204
|
+
this.startPromise = startup;
|
|
205
|
+
}
|
|
206
|
+
return this.startPromise;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async doStart(): Promise<void> {
|
|
210
|
+
this.state = "starting";
|
|
211
|
+
installProcessCleanupOnce();
|
|
212
|
+
liveEngines.add(this);
|
|
213
|
+
const pythonPath = this.pythonPath;
|
|
214
|
+
const child = spawn(pythonPath, [GUEST_PATH], {
|
|
215
|
+
cwd: this.options.cwd,
|
|
216
|
+
env: {
|
|
217
|
+
...process.env,
|
|
218
|
+
...(this.options.env ?? {}),
|
|
219
|
+
[NONCE_ENV]: this.nonce,
|
|
220
|
+
PI_REPL_TIMEOUT_MS: String(this.timeoutMs),
|
|
221
|
+
PI_TOOLBOX_DIR: this.toolboxDir ?? "",
|
|
222
|
+
},
|
|
223
|
+
// fd 3 carries protocol; stdout/stderr stay user output.
|
|
224
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
225
|
+
});
|
|
226
|
+
this.child = child;
|
|
227
|
+
this.childClosed = new Promise((resolve) => child.once("close", () => resolve()));
|
|
228
|
+
|
|
229
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
230
|
+
const timer = setTimeout(() => reject(new Error("Engine guest did not become ready in time")), READY_TIMEOUT_MS);
|
|
231
|
+
timer.unref?.();
|
|
232
|
+
this.pendingRequests.set("__ready__", {
|
|
233
|
+
resolve: () => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
resolve();
|
|
236
|
+
},
|
|
237
|
+
reject: (error) => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
reject(error);
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const protocolStream = child.stdio[PROTOCOL_FD] as NodeJS.ReadableStream | null;
|
|
245
|
+
if (!protocolStream) {
|
|
246
|
+
throw new Error("Engine guest was spawned without a protocol pipe on fd 3");
|
|
247
|
+
}
|
|
248
|
+
this.protocolReader = createInterface({ input: protocolStream });
|
|
249
|
+
this.protocolReader.on("line", (line) => this.handleGuestLine(line));
|
|
250
|
+
// Anything the guest writes to the real stdout/stderr fds is subprocess
|
|
251
|
+
// output (Bun.$ without .quiet()); attribute it to the running cell.
|
|
252
|
+
child.stdout!.on("data", (buffer: Buffer) => this.appendActiveOutput("stdout", buffer.toString()));
|
|
253
|
+
child.stderr!.on("data", (buffer: Buffer) => {
|
|
254
|
+
const text = buffer.toString();
|
|
255
|
+
this.guestStderr = (this.guestStderr + text).slice(-4000);
|
|
256
|
+
this.appendActiveOutput("stderr", text);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
child.on("error", (error) => {
|
|
260
|
+
// --- ENOENT names a missing python; say what to install ---
|
|
261
|
+
const message =
|
|
262
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
263
|
+
? "Engine process failed: '" +
|
|
264
|
+
pythonPath +
|
|
265
|
+
"' was not found on PATH. pi-repl runs its evaluator in Python; ensure it is installed and on your PATH, or set the pythonPath in ~/.pi/agent/pi-repl.json."
|
|
266
|
+
: `Engine process failed: ${error.message}`;
|
|
267
|
+
this.failAllPending(new Error(message));
|
|
268
|
+
this.transitionToShutdown(message);
|
|
269
|
+
});
|
|
270
|
+
child.on("exit", (code, signal) => {
|
|
271
|
+
// --- a killed child's exit arrives after teardown already moved on ---
|
|
272
|
+
if (this.child !== child) return;
|
|
273
|
+
if (this.state !== "shutdown") {
|
|
274
|
+
const tail = this.guestStderr.trim();
|
|
275
|
+
const reason =
|
|
276
|
+
`Engine process exited unexpectedly (code=${code} signal=${signal})` +
|
|
277
|
+
(tail ? `\nguest stderr:\n${tail.slice(-1500)}` : "");
|
|
278
|
+
this.failAllPending(new Error(reason));
|
|
279
|
+
this.transitionToShutdown(reason);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// On a boot timeout the child must be torn down and the state reset to
|
|
284
|
+
// idle, or a retried start() orphans the previous child and its fd3 pipe.
|
|
285
|
+
await ready.catch((error) => {
|
|
286
|
+
if (this.child === child) this.child = undefined;
|
|
287
|
+
this.protocolReader?.close();
|
|
288
|
+
this.protocolReader = undefined;
|
|
289
|
+
child.kill("SIGKILL");
|
|
290
|
+
if (this.state === "starting") this.state = "idle";
|
|
291
|
+
throw error;
|
|
292
|
+
});
|
|
293
|
+
// --- win the shutdown race: don't resurrect a killed engine as running ---
|
|
294
|
+
if ((this.state as string) === "shutdown") throw new Error("Engine has been shut down");
|
|
295
|
+
this.state = "running";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private transitionToShutdown(reason: string): void {
|
|
299
|
+
this.state = "shutdown";
|
|
300
|
+
this.clearSnapshotTimer();
|
|
301
|
+
const active = this.activeExecution;
|
|
302
|
+
if (active && !active.settled) {
|
|
303
|
+
this.activeExecution = undefined;
|
|
304
|
+
active.settled = true;
|
|
305
|
+
active.reject(new Error(reason));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private failAllPending(error: Error): void {
|
|
310
|
+
for (const [, pending] of this.pendingRequests) {
|
|
311
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
312
|
+
pending.reject(error);
|
|
313
|
+
}
|
|
314
|
+
this.pendingRequests.clear();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async kill(): Promise<void> {
|
|
318
|
+
const closed = this.childClosed;
|
|
319
|
+
this.killSync();
|
|
320
|
+
// --- wait for pipes to close so a fast respawn doesn't recycle descriptors ---
|
|
321
|
+
if (closed) {
|
|
322
|
+
await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref?.())]);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Synchronous teardown, safe from process.on("exit"). */
|
|
327
|
+
killSync(): void {
|
|
328
|
+
this.clearSnapshotTimer();
|
|
329
|
+
const active = this.activeExecution;
|
|
330
|
+
if (active && !active.settled) {
|
|
331
|
+
active.status = "aborted";
|
|
332
|
+
this.settleActiveExecution(active);
|
|
333
|
+
}
|
|
334
|
+
this.state = "shutdown";
|
|
335
|
+
liveEngines.delete(this);
|
|
336
|
+
this.failAllPending(new Error("Engine has been shut down"));
|
|
337
|
+
this.child?.kill("SIGKILL");
|
|
338
|
+
this.child = undefined;
|
|
339
|
+
this.protocolReader?.close();
|
|
340
|
+
this.protocolReader = undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Graceful cleanup: flush a final snapshot, then terminate the guest. */
|
|
344
|
+
async dispose(): Promise<void> {
|
|
345
|
+
if (this.state === "running") {
|
|
346
|
+
await this.snapshotState().catch(() => null);
|
|
347
|
+
}
|
|
348
|
+
await this.kill();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── guest messaging ────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
private sendToGuest(message: HostToGuestMessage): void {
|
|
354
|
+
// --- a write into a dying child can throw; callers learn via the exit path ---
|
|
355
|
+
try {
|
|
356
|
+
this.child?.stdin?.write(encodeMessage(message, this.nonce));
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private request(message: HostToGuestMessage & { id: string }, timeoutMs: number): Promise<GuestToHostMessage> {
|
|
361
|
+
const pending = new Promise<GuestToHostMessage>((resolve, reject) => {
|
|
362
|
+
const timer = setTimeout(() => {
|
|
363
|
+
this.pendingRequests.delete(message.id);
|
|
364
|
+
reject(new Error(`Engine request ${message.type} timed out`));
|
|
365
|
+
}, timeoutMs);
|
|
366
|
+
timer.unref?.();
|
|
367
|
+
this.pendingRequests.set(message.id, { resolve, reject, timer });
|
|
368
|
+
this.sendToGuest(message);
|
|
369
|
+
});
|
|
370
|
+
// --- a caller that moved on isn't listening; that rejection could escape as unhandled ---
|
|
371
|
+
pending.catch(() => {});
|
|
372
|
+
return pending;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private handleGuestLine(line: string): void {
|
|
376
|
+
// fd 3 is protocol-only; a line that fails to decode is discarded.
|
|
377
|
+
const message = decodeMessage<GuestToHostMessage>(line, this.nonce);
|
|
378
|
+
if (!message) return;
|
|
379
|
+
switch (message.type) {
|
|
380
|
+
case "ready": {
|
|
381
|
+
const pending = this.pendingRequests.get("__ready__");
|
|
382
|
+
if (pending) {
|
|
383
|
+
this.pendingRequests.delete("__ready__");
|
|
384
|
+
pending.resolve(message);
|
|
385
|
+
}
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "stream": {
|
|
389
|
+
const active = this.activeExecution;
|
|
390
|
+
// --- untagged output belongs to no cell; don't attribute it ---
|
|
391
|
+
if (!active || active.settled || message.cellId !== active.cellId) return;
|
|
392
|
+
this.appendOutput(active, message.name, message.chunk);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case "done": {
|
|
396
|
+
const active = this.activeExecution;
|
|
397
|
+
if (!active || active.settled || active.cellId !== message.cellId) return;
|
|
398
|
+
if (message.status === "error") {
|
|
399
|
+
active.status = "error";
|
|
400
|
+
active.error = message.error;
|
|
401
|
+
} else if (message.status === "aborted") {
|
|
402
|
+
active.status = "aborted";
|
|
403
|
+
} else {
|
|
404
|
+
active.result = message.result;
|
|
405
|
+
}
|
|
406
|
+
this.settleActiveExecution(active);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "pong": {
|
|
410
|
+
this.resolveRequest(message.id, message);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case "snapshot_result":
|
|
414
|
+
case "restore_result":
|
|
415
|
+
case "names_result": {
|
|
416
|
+
this.resolveRequest(message.id, message);
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private resolveRequest(id: string, message: GuestToHostMessage): void {
|
|
423
|
+
const pending = this.pendingRequests.get(id);
|
|
424
|
+
if (!pending) return;
|
|
425
|
+
this.pendingRequests.delete(id);
|
|
426
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
427
|
+
pending.resolve(message);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── output accumulation ────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
private appendActiveOutput(name: "stdout" | "stderr", text: string): void {
|
|
433
|
+
const active = this.activeExecution;
|
|
434
|
+
if (!active || active.settled) return;
|
|
435
|
+
this.appendOutput(active, name, text);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private appendOutput(active: ActiveExecution, name: "stdout" | "stderr", text: string): void {
|
|
439
|
+
if (active.abortRequested) return;
|
|
440
|
+
const key = name === "stdout" ? "stdout" : "stderr";
|
|
441
|
+
const truncatedKey = name === "stdout" ? "stdoutTruncated" : "stderrTruncated";
|
|
442
|
+
if (active[key].length < active.maxChars) {
|
|
443
|
+
active[key] += text;
|
|
444
|
+
if (active[key].length > active.maxChars) {
|
|
445
|
+
active[key] = active[key].slice(0, active.maxChars);
|
|
446
|
+
active[truncatedKey] = true;
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
active[truncatedKey] = true;
|
|
450
|
+
}
|
|
451
|
+
// --- cap the live stream feed too, so index.ts's accumulated partial
|
|
452
|
+
// content cannot grow past the same budget the final output is capped at ---
|
|
453
|
+
const room = active.maxChars - active.streamedChars;
|
|
454
|
+
const forward = Math.min(text.length, Math.max(0, room));
|
|
455
|
+
if (forward > 0) active.opts.onStream?.(text.slice(0, forward), name);
|
|
456
|
+
active.streamedChars += forward;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── execute ────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
462
|
+
// --- claim the queue slot synchronously so order == submission order ---
|
|
463
|
+
const previous = this.executionQueue;
|
|
464
|
+
let release: () => void = () => {};
|
|
465
|
+
this.executionQueue = new Promise<void>((resolve) => {
|
|
466
|
+
release = resolve;
|
|
467
|
+
});
|
|
468
|
+
await previous;
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
if (opts.signal?.aborted) {
|
|
472
|
+
return { stdout: "", stderr: "", status: "aborted", durationMs: 0 };
|
|
473
|
+
}
|
|
474
|
+
if (this.state === "shutdown") {
|
|
475
|
+
throw new Error("Engine has been shut down");
|
|
476
|
+
}
|
|
477
|
+
await this.start();
|
|
478
|
+
if ((this.state as string) === "shutdown") {
|
|
479
|
+
throw new Error("Engine has been shut down");
|
|
480
|
+
}
|
|
481
|
+
if (this.maybeWedged) {
|
|
482
|
+
await this.assertGuestResponsive();
|
|
483
|
+
}
|
|
484
|
+
const result = await this.executeInner(code, opts);
|
|
485
|
+
if (result.status === "ok") this.scheduleSnapshot();
|
|
486
|
+
return result;
|
|
487
|
+
} finally {
|
|
488
|
+
release();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async assertGuestResponsive(): Promise<void> {
|
|
493
|
+
try {
|
|
494
|
+
await this.request({ type: "ping", id: randomUUID() }, PING_TIMEOUT_MS);
|
|
495
|
+
this.maybeWedged = false;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (this.state === "shutdown" || !this.child) {
|
|
498
|
+
throw new Error("Engine has been shut down");
|
|
499
|
+
}
|
|
500
|
+
void error;
|
|
501
|
+
throw new EngineBusyError();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private executeInner(code: string, opts: ExecuteOptions): Promise<ExecuteResult> {
|
|
506
|
+
const cellId = randomUUID();
|
|
507
|
+
const started = Date.now();
|
|
508
|
+
|
|
509
|
+
return new Promise<ExecuteResult>((resolve, reject) => {
|
|
510
|
+
const active: ActiveExecution = {
|
|
511
|
+
cellId,
|
|
512
|
+
code,
|
|
513
|
+
started,
|
|
514
|
+
maxChars: opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS,
|
|
515
|
+
opts,
|
|
516
|
+
stdout: "",
|
|
517
|
+
stderr: "",
|
|
518
|
+
stdoutTruncated: false,
|
|
519
|
+
stderrTruncated: false,
|
|
520
|
+
status: "ok",
|
|
521
|
+
settled: false,
|
|
522
|
+
abortRequested: false,
|
|
523
|
+
streamedChars: 0,
|
|
524
|
+
hostAbort: new AbortController(),
|
|
525
|
+
resolve,
|
|
526
|
+
reject,
|
|
527
|
+
};
|
|
528
|
+
this.activeExecution = active;
|
|
529
|
+
|
|
530
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
531
|
+
const onAbort = () => {
|
|
532
|
+
active.abortRequested = true;
|
|
533
|
+
active.hostAbort.abort();
|
|
534
|
+
this.sendToGuest({ type: "abort", cellId });
|
|
535
|
+
this.maybeWedged = true;
|
|
536
|
+
graceTimer = setTimeout(() => {
|
|
537
|
+
if (this.activeExecution === active && !active.settled) {
|
|
538
|
+
active.status = "aborted";
|
|
539
|
+
this.settleActiveExecution(active);
|
|
540
|
+
}
|
|
541
|
+
}, ABORT_GRACE_MS);
|
|
542
|
+
graceTimer.unref?.();
|
|
543
|
+
};
|
|
544
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
545
|
+
|
|
546
|
+
const originalResolve = active.resolve;
|
|
547
|
+
active.resolve = (result) => {
|
|
548
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
549
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
550
|
+
originalResolve(result);
|
|
551
|
+
};
|
|
552
|
+
const originalReject = active.reject;
|
|
553
|
+
active.reject = (error) => {
|
|
554
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
555
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
556
|
+
originalReject(error);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
this.sendToGuest({ type: "run", cellId, code });
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private settleActiveExecution(active: ActiveExecution): void {
|
|
564
|
+
if (active.settled) return;
|
|
565
|
+
active.settled = true;
|
|
566
|
+
if (this.activeExecution === active) this.activeExecution = undefined;
|
|
567
|
+
|
|
568
|
+
// A cancelled cell reports "aborted" even if it finished first:
|
|
569
|
+
// the caller withdrew interest, so the value is not theirs to consume.
|
|
570
|
+
let status = active.status;
|
|
571
|
+
if (active.opts.signal?.aborted) status = "aborted";
|
|
572
|
+
if (status !== "aborted") this.maybeWedged = false;
|
|
573
|
+
|
|
574
|
+
const stdout = truncateWithMarker(active.stdout, active.maxChars, active.stdoutTruncated);
|
|
575
|
+
const stderr = truncateWithMarker(active.stderr, active.maxChars, active.stderrTruncated);
|
|
576
|
+
let result = active.result;
|
|
577
|
+
if (result != null && String(result).length > active.maxChars) {
|
|
578
|
+
result = truncateWithMarker(String(result), active.maxChars, true);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
active.resolve({
|
|
582
|
+
stdout,
|
|
583
|
+
stderr,
|
|
584
|
+
result,
|
|
585
|
+
error: active.error,
|
|
586
|
+
status,
|
|
587
|
+
durationMs: Date.now() - active.started,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── snapshot / restore / names ─────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
async snapshotState(): Promise<SnapshotResult | null> {
|
|
594
|
+
const config = this.options.snapshot;
|
|
595
|
+
if (!config || this.state !== "running") return null;
|
|
596
|
+
try {
|
|
597
|
+
const reply = await this.request({ type: "snapshot", id: randomUUID() }, SNAPSHOT_REQUEST_TIMEOUT_MS);
|
|
598
|
+
if (reply.type !== "snapshot_result") return null;
|
|
599
|
+
// An incomplete snapshot (the guest stalled mid-serialization) must
|
|
600
|
+
// NOT overwrite the last good file — a failed snapshot should cost a
|
|
601
|
+
// throwaway run, never the durable memory.
|
|
602
|
+
if (reply.complete === false) return null;
|
|
603
|
+
mkdirSync(dirname(config.path), { recursive: true });
|
|
604
|
+
writeFileSync(config.path, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
|
|
605
|
+
return { path: config.path, saved: Object.keys(reply.vars), failed: reply.failed };
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async restoreState(): Promise<RestoreResult | null> {
|
|
612
|
+
const config = this.options.snapshot;
|
|
613
|
+
if (!config) return null;
|
|
614
|
+
if (!existsSync(config.path)) return null;
|
|
615
|
+
await this.start();
|
|
616
|
+
try {
|
|
617
|
+
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
618
|
+
vars?: Record<string, string>;
|
|
619
|
+
};
|
|
620
|
+
const vars = payload.vars ?? {};
|
|
621
|
+
const reply = await this.request({ type: "restore", id: randomUUID(), vars }, SNAPSHOT_REQUEST_TIMEOUT_MS);
|
|
622
|
+
if (reply.type !== "restore_result") return null;
|
|
623
|
+
return { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async listNamespaceNames(): Promise<string[] | null> {
|
|
630
|
+
if (this.state !== "running") return null;
|
|
631
|
+
try {
|
|
632
|
+
const reply = await this.request({ type: "list_names", id: randomUUID() }, PING_TIMEOUT_MS);
|
|
633
|
+
return reply.type === "names_result" ? reply.names : null;
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private scheduleSnapshot(): void {
|
|
640
|
+
const config = this.options.snapshot;
|
|
641
|
+
if (!config) return;
|
|
642
|
+
this.clearSnapshotTimer();
|
|
643
|
+
this.snapshotTimer = setTimeout(() => {
|
|
644
|
+
this.snapshotTimer = undefined;
|
|
645
|
+
void this.snapshotState();
|
|
646
|
+
}, config.debounceMs ?? DEFAULT_SNAPSHOT_DEBOUNCE_MS);
|
|
647
|
+
this.snapshotTimer.unref?.();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private clearSnapshotTimer(): void {
|
|
651
|
+
if (this.snapshotTimer) {
|
|
652
|
+
clearTimeout(this.snapshotTimer);
|
|
653
|
+
this.snapshotTimer = undefined;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|