pi-repl-py 0.1.0 → 0.2.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/README.md +31 -20
- package/docs/ARCHITECTURE.md +182 -0
- package/docs/how-to-functions.md +82 -79
- package/docs/philosophy.md +67 -59
- package/index.ts +8 -31
- package/package.json +12 -6
- package/scripts/setup-venv.mjs +41 -19
- package/src/engine/index.ts +92 -433
- package/src/engine/kernel.ts +598 -0
- package/src/engine/session.ts +149 -0
- package/src/engine/zmtp.ts +251 -0
- package/src/extension/helpers.ts +46 -0
- package/src/extension/preview/candidates.ts +159 -0
- package/src/extension/preview/descriptor.ts +28 -0
- package/src/extension/preview/index.ts +31 -0
- package/src/extension/preview/scan.ts +59 -0
- package/src/extension/preview/shell.ts +156 -0
- package/src/extension/preview/types.ts +23 -0
- package/src/extension/prompt.ts +76 -0
- package/src/extension/render-core.ts +11 -31
- package/src/extension/render.ts +2 -11
- package/src/extension/session-engine.ts +9 -31
- package/src/extension/tool-meta.ts +11 -54
- package/ARCHITECTURE.md +0 -141
- package/src/engine/guest.py +0 -317
- package/src/engine/protocol.ts +0 -66
- package/src/engine/toolbox/bash.py +0 -72
- package/src/engine/toolbox/edit.py +0 -37
- package/src/engine/toolbox/read.py +0 -26
- package/src/engine/toolbox/write.py +0 -23
- package/src/extension/config.ts +0 -65
- package/src/extension/preview-core.ts +0 -518
- package/src/extension/toolbox.ts +0 -74
package/src/engine/index.ts
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
|
-
// --- EngineManager: the host half
|
|
1
|
+
// --- EngineManager: the host half of pi-repl's evaluator, driving a real ipykernel over ---
|
|
2
|
+
// --- ZMTP directly (no guest.py middleman). Owns venv resolution, spawn, queue, ---
|
|
3
|
+
// --- snapshots, abort grace, and teardown — the wire lives in kernel.ts. ---
|
|
2
4
|
|
|
3
|
-
import { type ChildProcess, spawn } from "node:child_process";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
|
-
import { createInterface } from "node:readline";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 ---
|
|
9
|
+
import { KernelClient } from "./kernel.js";
|
|
10
|
+
|
|
11
|
+
const GUEST_REL = fileURLToPath(new URL("./kernel.js", import.meta.url));
|
|
12
|
+
|
|
22
13
|
function installVenvPython(): string {
|
|
23
|
-
return join(homedir(), ".pi", "agent", "pi-repl
|
|
14
|
+
return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
|
|
24
15
|
}
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
/** Prefer a venv with ipykernel; else $PYTHON or python3. */
|
|
27
18
|
function resolvePythonPath(cwd: string | undefined): string {
|
|
28
|
-
const repoVenv = join(dirname(
|
|
19
|
+
const repoVenv = join(dirname(GUEST_REL), "..", "..", ".venv", "bin", "python3");
|
|
29
20
|
if (existsSync(repoVenv)) return repoVenv;
|
|
30
21
|
const cwdVenv = cwd ? join(cwd, ".venv", "bin", "python3") : "";
|
|
31
22
|
if (cwdVenv && existsSync(cwdVenv)) return cwdVenv;
|
|
@@ -33,12 +24,10 @@ function resolvePythonPath(cwd: string | undefined): string {
|
|
|
33
24
|
if (existsSync(installVenv)) return installVenv;
|
|
34
25
|
return process.env.PYTHON ?? "python3";
|
|
35
26
|
}
|
|
27
|
+
|
|
36
28
|
const DEFAULT_MAX_OUTPUT_CHARS = 65536;
|
|
37
|
-
const READY_TIMEOUT_MS = 30_000;
|
|
38
29
|
const ABORT_GRACE_MS = 500;
|
|
39
|
-
const PING_TIMEOUT_MS = 5_000;
|
|
40
30
|
const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500;
|
|
41
|
-
const SNAPSHOT_REQUEST_TIMEOUT_MS = 30_000;
|
|
42
31
|
|
|
43
32
|
interface EngineExecuteError {
|
|
44
33
|
/** Error class name, e.g. "TypeError". */
|
|
@@ -59,7 +48,7 @@ export interface ExecuteResult {
|
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
export interface ExecuteOptions {
|
|
62
|
-
/** Aborting cancels the cell
|
|
51
|
+
/** Aborting cancels the cell via kernel interrupt; the namespace is preserved. */
|
|
63
52
|
signal?: AbortSignal;
|
|
64
53
|
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
65
54
|
/** Cap stdout / stderr / result at this many characters. Default 65536. */
|
|
@@ -82,12 +71,6 @@ export interface RestoreResult {
|
|
|
82
71
|
|
|
83
72
|
export interface EngineOptions {
|
|
84
73
|
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
74
|
env?: Record<string, string>;
|
|
92
75
|
/** Persist/revive the namespace across engine restarts. */
|
|
93
76
|
snapshot?: {
|
|
@@ -97,45 +80,7 @@ export interface EngineOptions {
|
|
|
97
80
|
};
|
|
98
81
|
}
|
|
99
82
|
|
|
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.
|
|
83
|
+
// --- process-wide cleanup: a child does not die with its parent, so SIGKILL live kernels on exit ---
|
|
139
84
|
|
|
140
85
|
const liveEngines = new Set<EngineManager>();
|
|
141
86
|
let cleanupHandlersInstalled = false;
|
|
@@ -148,12 +93,6 @@ function installProcessCleanupOnce(): void {
|
|
|
148
93
|
});
|
|
149
94
|
}
|
|
150
95
|
|
|
151
|
-
interface PendingRequest {
|
|
152
|
-
resolve(message: GuestToHostMessage): void;
|
|
153
|
-
reject(error: Error): void;
|
|
154
|
-
timer?: ReturnType<typeof setTimeout>;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
96
|
function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolean): string {
|
|
158
97
|
if (!wasTruncated && text.length <= maxChars) return text;
|
|
159
98
|
return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
|
|
@@ -161,36 +100,28 @@ function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolea
|
|
|
161
100
|
|
|
162
101
|
export class EngineManager {
|
|
163
102
|
private readonly options: EngineOptions;
|
|
164
|
-
private
|
|
165
|
-
private readonly toolboxDir?: string;
|
|
166
|
-
private readonly timeoutMs: number;
|
|
167
|
-
private child?: ChildProcess;
|
|
103
|
+
private kernel?: KernelClient;
|
|
168
104
|
private state: "idle" | "starting" | "running" | "shutdown" = "idle";
|
|
169
105
|
private startPromise?: Promise<void>;
|
|
170
106
|
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
107
|
private snapshotTimer?: ReturnType<typeof setTimeout>;
|
|
108
|
+
private pythonPath?: string;
|
|
181
109
|
|
|
182
110
|
constructor(options: EngineOptions = {}) {
|
|
183
111
|
this.options = options;
|
|
184
|
-
this.pythonPath = options.pythonPath ?? resolvePythonPath(options.cwd);
|
|
185
|
-
this.toolboxDir = options.toolboxDir;
|
|
186
|
-
this.timeoutMs = options.timeoutMs ?? 0;
|
|
187
112
|
}
|
|
188
113
|
|
|
189
114
|
get isRunning(): boolean {
|
|
190
|
-
return this.state === "running";
|
|
115
|
+
return this.state === "running" && (this.kernel?.isRunning ?? false);
|
|
191
116
|
}
|
|
192
117
|
|
|
193
|
-
//
|
|
118
|
+
// -- state can change to "shutdown" from kill()/dispose() at any time; read it
|
|
119
|
+
// through a method so TS doesn't narrow the union and flag a false "no overlap" --
|
|
120
|
+
private isShutdown(): boolean {
|
|
121
|
+
return this.state === "shutdown";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//lifecycle
|
|
194
125
|
|
|
195
126
|
async start(): Promise<void> {
|
|
196
127
|
if (this.state === "shutdown") throw new Error("Engine has been shut down");
|
|
@@ -210,254 +141,50 @@ export class EngineManager {
|
|
|
210
141
|
this.state = "starting";
|
|
211
142
|
installProcessCleanupOnce();
|
|
212
143
|
liveEngines.add(this);
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
},
|
|
144
|
+
this.pythonPath = resolvePythonPath(this.options.cwd);
|
|
145
|
+
const timeoutMs = Number(process.env.PI_REPL_TIMEOUT_MS ?? this.options.env?.PI_REPL_TIMEOUT_MS ?? 0) || 0;
|
|
146
|
+
try {
|
|
147
|
+
this.kernel = await KernelClient.start(this.pythonPath, {
|
|
148
|
+
cwd: this.options.cwd,
|
|
149
|
+
env: this.options.env,
|
|
150
|
+
timeoutMs,
|
|
241
151
|
});
|
|
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");
|
|
152
|
+
} catch (error) {
|
|
290
153
|
if (this.state === "starting") this.state = "idle";
|
|
154
|
+
liveEngines.delete(this);
|
|
291
155
|
throw error;
|
|
292
|
-
}
|
|
156
|
+
}
|
|
293
157
|
// --- win the shutdown race: don't resurrect a killed engine as running ---
|
|
294
|
-
if (
|
|
158
|
+
if (this.isShutdown()) {
|
|
159
|
+
this.kernel?.kill();
|
|
160
|
+
this.kernel = undefined;
|
|
161
|
+
throw new Error("Engine has been shut down");
|
|
162
|
+
}
|
|
295
163
|
this.state = "running";
|
|
296
164
|
}
|
|
297
165
|
|
|
298
|
-
|
|
299
|
-
|
|
166
|
+
/** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
|
|
167
|
+
killSync(): void {
|
|
300
168
|
this.clearSnapshotTimer();
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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();
|
|
169
|
+
this.state = "shutdown";
|
|
170
|
+
liveEngines.delete(this);
|
|
171
|
+
this.kernel?.kill();
|
|
172
|
+
this.kernel = undefined;
|
|
315
173
|
}
|
|
316
174
|
|
|
317
175
|
async kill(): Promise<void> {
|
|
318
|
-
const closed = this.childClosed;
|
|
319
176
|
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
177
|
}
|
|
325
178
|
|
|
326
|
-
/**
|
|
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. */
|
|
179
|
+
/** Graceful cleanup: flush a final snapshot, then terminate the kernel. */
|
|
344
180
|
async dispose(): Promise<void> {
|
|
345
181
|
if (this.state === "running") {
|
|
346
182
|
await this.snapshotState().catch(() => null);
|
|
347
183
|
}
|
|
348
|
-
await this.
|
|
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;
|
|
184
|
+
await this.kernel?.shutdown();
|
|
185
|
+
this.killSync();
|
|
457
186
|
}
|
|
458
187
|
|
|
459
|
-
// ── execute ────────────────────────────────────────────────────────────────
|
|
460
|
-
|
|
461
188
|
async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
462
189
|
// --- claim the queue slot synchronously so order == submission order ---
|
|
463
190
|
const previous = this.executionQueue;
|
|
@@ -471,134 +198,70 @@ export class EngineManager {
|
|
|
471
198
|
if (opts.signal?.aborted) {
|
|
472
199
|
return { stdout: "", stderr: "", status: "aborted", durationMs: 0 };
|
|
473
200
|
}
|
|
474
|
-
if (this.
|
|
201
|
+
if (this.isShutdown()) {
|
|
475
202
|
throw new Error("Engine has been shut down");
|
|
476
203
|
}
|
|
477
204
|
await this.start();
|
|
478
|
-
if (
|
|
205
|
+
if (this.isShutdown()) {
|
|
479
206
|
throw new Error("Engine has been shut down");
|
|
480
207
|
}
|
|
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
208
|
|
|
209
|
+
const started = Date.now();
|
|
210
|
+
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
211
|
+
let aborted = false;
|
|
530
212
|
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
531
213
|
const onAbort = () => {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
this.maybeWedged = true;
|
|
214
|
+
aborted = true;
|
|
215
|
+
this.kernel?.interrupt();
|
|
216
|
+
// --- interrupt is a real KeyboardInterrupt, but a C-wedged cell ignores it; then kill+rebuild ---
|
|
536
217
|
graceTimer = setTimeout(() => {
|
|
537
|
-
if (this.
|
|
538
|
-
|
|
539
|
-
this.
|
|
218
|
+
if (this.state === "running" && this.kernel) {
|
|
219
|
+
this.state = "shutdown";
|
|
220
|
+
this.kernel.kill();
|
|
221
|
+
this.kernel = undefined;
|
|
540
222
|
}
|
|
541
223
|
}, ABORT_GRACE_MS);
|
|
542
224
|
graceTimer.unref?.();
|
|
543
225
|
};
|
|
544
226
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
545
227
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
228
|
+
try {
|
|
229
|
+
const r = await this.kernel!.executeCell(code, {
|
|
230
|
+
signal: opts.signal,
|
|
231
|
+
onStream: opts.onStream,
|
|
232
|
+
maxOutputChars: maxChars,
|
|
233
|
+
});
|
|
234
|
+
if (r.status === "ok") this.scheduleSnapshot();
|
|
235
|
+
const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
|
|
236
|
+
const truncate = (text: string, truncated: boolean) => truncateWithMarker(text, maxChars, truncated);
|
|
237
|
+
return {
|
|
238
|
+
stdout: truncate(r.stdout, r.truncated?.stdout ?? false),
|
|
239
|
+
stderr: truncate(r.stderr, r.truncated?.stderr ?? false),
|
|
240
|
+
result: r.result !== undefined ? truncate(String(r.result), String(r.result).length > maxChars) : undefined,
|
|
241
|
+
error: r.error,
|
|
242
|
+
status,
|
|
243
|
+
durationMs: Date.now() - started,
|
|
244
|
+
};
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (aborted) {
|
|
247
|
+
return { stdout: "", stderr: "", status: "aborted", durationMs: Date.now() - started };
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
} finally {
|
|
554
251
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
555
252
|
if (graceTimer) clearTimeout(graceTimer);
|
|
556
|
-
|
|
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);
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
release();
|
|
579
256
|
}
|
|
580
|
-
|
|
581
|
-
active.resolve({
|
|
582
|
-
stdout,
|
|
583
|
-
stderr,
|
|
584
|
-
result,
|
|
585
|
-
error: active.error,
|
|
586
|
-
status,
|
|
587
|
-
durationMs: Date.now() - active.started,
|
|
588
|
-
});
|
|
589
257
|
}
|
|
590
258
|
|
|
591
|
-
// ── snapshot / restore / names ─────────────────────────────────────────────
|
|
592
|
-
|
|
593
259
|
async snapshotState(): Promise<SnapshotResult | null> {
|
|
594
260
|
const config = this.options.snapshot;
|
|
595
|
-
if (!config || this.state !== "running") return null;
|
|
261
|
+
if (!config || this.state !== "running" || !this.kernel) return null;
|
|
596
262
|
try {
|
|
597
|
-
const reply = await this.
|
|
598
|
-
|
|
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.
|
|
263
|
+
const reply = await this.kernel.snapshot();
|
|
264
|
+
// --- an incomplete snapshot must not overwrite the last good file ---
|
|
602
265
|
if (reply.complete === false) return null;
|
|
603
266
|
mkdirSync(dirname(config.path), { recursive: true });
|
|
604
267
|
writeFileSync(config.path, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
|
|
@@ -614,12 +277,9 @@ export class EngineManager {
|
|
|
614
277
|
if (!existsSync(config.path)) return null;
|
|
615
278
|
await this.start();
|
|
616
279
|
try {
|
|
617
|
-
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
618
|
-
vars?: Record<string, string>;
|
|
619
|
-
};
|
|
280
|
+
const payload = JSON.parse(readFileSync(config.path, "utf8")) as { vars?: Record<string, string> };
|
|
620
281
|
const vars = payload.vars ?? {};
|
|
621
|
-
const reply = await this.
|
|
622
|
-
if (reply.type !== "restore_result") return null;
|
|
282
|
+
const reply = await this.kernel!.restore(vars);
|
|
623
283
|
return { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
624
284
|
} catch {
|
|
625
285
|
return null;
|
|
@@ -627,10 +287,9 @@ export class EngineManager {
|
|
|
627
287
|
}
|
|
628
288
|
|
|
629
289
|
async listNamespaceNames(): Promise<string[] | null> {
|
|
630
|
-
if (this.state !== "running") return null;
|
|
290
|
+
if (this.state !== "running" || !this.kernel) return null;
|
|
631
291
|
try {
|
|
632
|
-
|
|
633
|
-
return reply.type === "names_result" ? reply.names : null;
|
|
292
|
+
return await this.kernel.listNames();
|
|
634
293
|
} catch {
|
|
635
294
|
return null;
|
|
636
295
|
}
|