pi-repl-py 0.1.1 → 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 +21 -22
- package/docs/ARCHITECTURE.md +128 -88
- package/docs/how-to-functions.md +82 -80
- package/docs/philosophy.md +67 -59
- package/index.ts +4 -22
- package/package.json +5 -6
- package/scripts/setup-venv.mjs +39 -17
- package/src/engine/index.ts +90 -424
- 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 +7 -53
- package/src/extension/preview/descriptor.ts +2 -2
- package/src/extension/preview/index.ts +3 -11
- package/src/extension/preview/shell.ts +4 -4
- package/src/extension/preview/types.ts +1 -1
- package/src/extension/prompt.ts +67 -45
- package/src/extension/render-core.ts +5 -9
- package/src/extension/render.ts +2 -11
- package/src/extension/session-engine.ts +9 -31
- package/src/extension/tool-meta.ts +6 -6
- package/src/engine/guest.py +0 -320
- 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 -64
- package/src/extension/preview-core.ts +0 -2
- package/src/extension/toolbox.ts +0 -98
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
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,44 +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: guests killed on exit; the guest self-exits on stdin EOF ---
|
|
83
|
+
// --- process-wide cleanup: a child does not die with its parent, so SIGKILL live kernels on exit ---
|
|
138
84
|
|
|
139
85
|
const liveEngines = new Set<EngineManager>();
|
|
140
86
|
let cleanupHandlersInstalled = false;
|
|
@@ -147,12 +93,6 @@ function installProcessCleanupOnce(): void {
|
|
|
147
93
|
});
|
|
148
94
|
}
|
|
149
95
|
|
|
150
|
-
interface PendingRequest {
|
|
151
|
-
resolve(message: GuestToHostMessage): void;
|
|
152
|
-
reject(error: Error): void;
|
|
153
|
-
timer?: ReturnType<typeof setTimeout>;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
96
|
function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolean): string {
|
|
157
97
|
if (!wasTruncated && text.length <= maxChars) return text;
|
|
158
98
|
return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
|
|
@@ -160,36 +100,28 @@ function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolea
|
|
|
160
100
|
|
|
161
101
|
export class EngineManager {
|
|
162
102
|
private readonly options: EngineOptions;
|
|
163
|
-
private
|
|
164
|
-
private readonly toolboxDir?: string;
|
|
165
|
-
private readonly timeoutMs: number;
|
|
166
|
-
private child?: ChildProcess;
|
|
103
|
+
private kernel?: KernelClient;
|
|
167
104
|
private state: "idle" | "starting" | "running" | "shutdown" = "idle";
|
|
168
105
|
private startPromise?: Promise<void>;
|
|
169
106
|
private executionQueue: Promise<unknown> = Promise.resolve();
|
|
170
|
-
private activeExecution?: ActiveExecution;
|
|
171
|
-
private readonly pendingRequests = new Map<string, PendingRequest>();
|
|
172
|
-
private readonly nonce = randomUUID().replaceAll("-", ""); // --- per-process protocol nonce ---
|
|
173
|
-
private guestStderr = ""; // --- tail of the guest's stderr, for unexpected-death reports ---
|
|
174
|
-
private childClosed?: Promise<void>;
|
|
175
|
-
/** Held so the protocol reader is not garbage-collected mid-session, which
|
|
176
|
-
* would close the guest's write end and kill it with EPIPE. */
|
|
177
|
-
private protocolReader?: ReturnType<typeof createInterface>;
|
|
178
|
-
private maybeWedged = false;
|
|
179
107
|
private snapshotTimer?: ReturnType<typeof setTimeout>;
|
|
108
|
+
private pythonPath?: string;
|
|
180
109
|
|
|
181
110
|
constructor(options: EngineOptions = {}) {
|
|
182
111
|
this.options = options;
|
|
183
|
-
this.pythonPath = options.pythonPath ?? resolvePythonPath(options.cwd);
|
|
184
|
-
this.toolboxDir = options.toolboxDir;
|
|
185
|
-
this.timeoutMs = options.timeoutMs ?? 0;
|
|
186
112
|
}
|
|
187
113
|
|
|
188
114
|
get isRunning(): boolean {
|
|
189
|
-
return this.state === "running";
|
|
115
|
+
return this.state === "running" && (this.kernel?.isRunning ?? false);
|
|
190
116
|
}
|
|
191
117
|
|
|
192
|
-
|
|
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
|
|
193
125
|
|
|
194
126
|
async start(): Promise<void> {
|
|
195
127
|
if (this.state === "shutdown") throw new Error("Engine has been shut down");
|
|
@@ -209,251 +141,50 @@ export class EngineManager {
|
|
|
209
141
|
this.state = "starting";
|
|
210
142
|
installProcessCleanupOnce();
|
|
211
143
|
liveEngines.add(this);
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
PI_REPL_TIMEOUT_MS: String(this.timeoutMs),
|
|
220
|
-
PI_TOOLBOX_DIR: this.toolboxDir ?? "",
|
|
221
|
-
},
|
|
222
|
-
// --- fd 3 is the protocol pipe; stdout/stderr stay user output ---
|
|
223
|
-
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
224
|
-
});
|
|
225
|
-
this.child = child;
|
|
226
|
-
this.childClosed = new Promise((resolve) => child.once("close", () => resolve()));
|
|
227
|
-
|
|
228
|
-
const ready = new Promise<void>((resolve, reject) => {
|
|
229
|
-
const timer = setTimeout(() => reject(new Error("Engine guest did not become ready in time")), READY_TIMEOUT_MS);
|
|
230
|
-
timer.unref?.();
|
|
231
|
-
this.pendingRequests.set("__ready__", {
|
|
232
|
-
resolve: () => {
|
|
233
|
-
clearTimeout(timer);
|
|
234
|
-
resolve();
|
|
235
|
-
},
|
|
236
|
-
reject: (error) => {
|
|
237
|
-
clearTimeout(timer);
|
|
238
|
-
reject(error);
|
|
239
|
-
},
|
|
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,
|
|
240
151
|
});
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
const protocolStream = child.stdio[PROTOCOL_FD] as NodeJS.ReadableStream | null;
|
|
244
|
-
if (!protocolStream) {
|
|
245
|
-
throw new Error("Engine guest was spawned without a protocol pipe on fd 3");
|
|
246
|
-
}
|
|
247
|
-
this.protocolReader = createInterface({ input: protocolStream });
|
|
248
|
-
this.protocolReader.on("line", (line) => this.handleGuestLine(line));
|
|
249
|
-
// --- guest stdout/stderr are subprocess output; attach to the running cell ---
|
|
250
|
-
child.stdout!.on("data", (buffer: Buffer) => this.appendActiveOutput("stdout", buffer.toString()));
|
|
251
|
-
child.stderr!.on("data", (buffer: Buffer) => {
|
|
252
|
-
const text = buffer.toString();
|
|
253
|
-
this.guestStderr = (this.guestStderr + text).slice(-4000);
|
|
254
|
-
this.appendActiveOutput("stderr", text);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
child.on("error", (error) => {
|
|
258
|
-
// --- ENOENT names a missing python; say what to install ---
|
|
259
|
-
const message =
|
|
260
|
-
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
261
|
-
? "Engine process failed: '" +
|
|
262
|
-
pythonPath +
|
|
263
|
-
"' 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/config.json."
|
|
264
|
-
: `Engine process failed: ${error.message}`;
|
|
265
|
-
this.failAllPending(new Error(message));
|
|
266
|
-
this.transitionToShutdown(message);
|
|
267
|
-
});
|
|
268
|
-
child.on("exit", (code, signal) => {
|
|
269
|
-
// --- a killed child's exit arrives after teardown already moved on ---
|
|
270
|
-
if (this.child !== child) return;
|
|
271
|
-
if (this.state !== "shutdown") {
|
|
272
|
-
const tail = this.guestStderr.trim();
|
|
273
|
-
const reason =
|
|
274
|
-
`Engine process exited unexpectedly (code=${code} signal=${signal})` +
|
|
275
|
-
(tail ? `\nguest stderr:\n${tail.slice(-1500)}` : "");
|
|
276
|
-
this.failAllPending(new Error(reason));
|
|
277
|
-
this.transitionToShutdown(reason);
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// --- on a boot timeout tear down the child and reset state, or a retried start orphans it ---
|
|
282
|
-
await ready.catch((error) => {
|
|
283
|
-
if (this.child === child) this.child = undefined;
|
|
284
|
-
this.protocolReader?.close();
|
|
285
|
-
this.protocolReader = undefined;
|
|
286
|
-
child.kill("SIGKILL");
|
|
152
|
+
} catch (error) {
|
|
287
153
|
if (this.state === "starting") this.state = "idle";
|
|
154
|
+
liveEngines.delete(this);
|
|
288
155
|
throw error;
|
|
289
|
-
}
|
|
156
|
+
}
|
|
290
157
|
// --- win the shutdown race: don't resurrect a killed engine as running ---
|
|
291
|
-
if (
|
|
158
|
+
if (this.isShutdown()) {
|
|
159
|
+
this.kernel?.kill();
|
|
160
|
+
this.kernel = undefined;
|
|
161
|
+
throw new Error("Engine has been shut down");
|
|
162
|
+
}
|
|
292
163
|
this.state = "running";
|
|
293
164
|
}
|
|
294
165
|
|
|
295
|
-
|
|
296
|
-
|
|
166
|
+
/** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
|
|
167
|
+
killSync(): void {
|
|
297
168
|
this.clearSnapshotTimer();
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
active.reject(new Error(reason));
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private failAllPending(error: Error): void {
|
|
307
|
-
for (const [, pending] of this.pendingRequests) {
|
|
308
|
-
if (pending.timer) clearTimeout(pending.timer);
|
|
309
|
-
pending.reject(error);
|
|
310
|
-
}
|
|
311
|
-
this.pendingRequests.clear();
|
|
169
|
+
this.state = "shutdown";
|
|
170
|
+
liveEngines.delete(this);
|
|
171
|
+
this.kernel?.kill();
|
|
172
|
+
this.kernel = undefined;
|
|
312
173
|
}
|
|
313
174
|
|
|
314
175
|
async kill(): Promise<void> {
|
|
315
|
-
const closed = this.childClosed;
|
|
316
176
|
this.killSync();
|
|
317
|
-
// --- wait for pipes to close so a fast respawn doesn't recycle descriptors ---
|
|
318
|
-
if (closed) {
|
|
319
|
-
await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref?.())]);
|
|
320
|
-
}
|
|
321
177
|
}
|
|
322
178
|
|
|
323
|
-
/**
|
|
324
|
-
killSync(): void {
|
|
325
|
-
this.clearSnapshotTimer();
|
|
326
|
-
const active = this.activeExecution;
|
|
327
|
-
if (active && !active.settled) {
|
|
328
|
-
active.status = "aborted";
|
|
329
|
-
this.settleActiveExecution(active);
|
|
330
|
-
}
|
|
331
|
-
this.state = "shutdown";
|
|
332
|
-
liveEngines.delete(this);
|
|
333
|
-
this.failAllPending(new Error("Engine has been shut down"));
|
|
334
|
-
this.child?.kill("SIGKILL");
|
|
335
|
-
this.child = undefined;
|
|
336
|
-
this.protocolReader?.close();
|
|
337
|
-
this.protocolReader = undefined;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** Graceful cleanup: flush a final snapshot, then terminate the guest. */
|
|
179
|
+
/** Graceful cleanup: flush a final snapshot, then terminate the kernel. */
|
|
341
180
|
async dispose(): Promise<void> {
|
|
342
181
|
if (this.state === "running") {
|
|
343
182
|
await this.snapshotState().catch(() => null);
|
|
344
183
|
}
|
|
345
|
-
await this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//--- guest messaging ---
|
|
349
|
-
|
|
350
|
-
private sendToGuest(message: HostToGuestMessage): void {
|
|
351
|
-
// --- a write into a dying child can throw; callers learn via the exit path ---
|
|
352
|
-
try {
|
|
353
|
-
this.child?.stdin?.write(encodeMessage(message, this.nonce));
|
|
354
|
-
} catch {}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
private request(message: HostToGuestMessage & { id: string }, timeoutMs: number): Promise<GuestToHostMessage> {
|
|
358
|
-
const pending = new Promise<GuestToHostMessage>((resolve, reject) => {
|
|
359
|
-
const timer = setTimeout(() => {
|
|
360
|
-
this.pendingRequests.delete(message.id);
|
|
361
|
-
reject(new Error(`Engine request ${message.type} timed out`));
|
|
362
|
-
}, timeoutMs);
|
|
363
|
-
timer.unref?.();
|
|
364
|
-
this.pendingRequests.set(message.id, { resolve, reject, timer });
|
|
365
|
-
this.sendToGuest(message);
|
|
366
|
-
});
|
|
367
|
-
// --- a caller that moved on isn't listening; that rejection could escape as unhandled ---
|
|
368
|
-
pending.catch(() => {});
|
|
369
|
-
return pending;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
private handleGuestLine(line: string): void {
|
|
373
|
-
// --- fd 3 is protocol-only; undecodable lines are discarded ---
|
|
374
|
-
const message = decodeMessage<GuestToHostMessage>(line, this.nonce);
|
|
375
|
-
if (!message) return;
|
|
376
|
-
switch (message.type) {
|
|
377
|
-
case "ready": {
|
|
378
|
-
const pending = this.pendingRequests.get("__ready__");
|
|
379
|
-
if (pending) {
|
|
380
|
-
this.pendingRequests.delete("__ready__");
|
|
381
|
-
pending.resolve(message);
|
|
382
|
-
}
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
case "stream": {
|
|
386
|
-
const active = this.activeExecution;
|
|
387
|
-
// --- untagged output belongs to no cell; don't attribute it ---
|
|
388
|
-
if (!active || active.settled || message.cellId !== active.cellId) return;
|
|
389
|
-
this.appendOutput(active, message.name, message.chunk);
|
|
390
|
-
break;
|
|
391
|
-
}
|
|
392
|
-
case "done": {
|
|
393
|
-
const active = this.activeExecution;
|
|
394
|
-
if (!active || active.settled || active.cellId !== message.cellId) return;
|
|
395
|
-
if (message.status === "error") {
|
|
396
|
-
active.status = "error";
|
|
397
|
-
active.error = message.error;
|
|
398
|
-
} else if (message.status === "aborted") {
|
|
399
|
-
active.status = "aborted";
|
|
400
|
-
} else {
|
|
401
|
-
active.result = message.result;
|
|
402
|
-
}
|
|
403
|
-
this.settleActiveExecution(active);
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
case "pong": {
|
|
407
|
-
this.resolveRequest(message.id, message);
|
|
408
|
-
break;
|
|
409
|
-
}
|
|
410
|
-
case "snapshot_result":
|
|
411
|
-
case "restore_result":
|
|
412
|
-
case "names_result": {
|
|
413
|
-
this.resolveRequest(message.id, message);
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
private resolveRequest(id: string, message: GuestToHostMessage): void {
|
|
420
|
-
const pending = this.pendingRequests.get(id);
|
|
421
|
-
if (!pending) return;
|
|
422
|
-
this.pendingRequests.delete(id);
|
|
423
|
-
if (pending.timer) clearTimeout(pending.timer);
|
|
424
|
-
pending.resolve(message);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
//--- output accumulation ---
|
|
428
|
-
|
|
429
|
-
private appendActiveOutput(name: "stdout" | "stderr", text: string): void {
|
|
430
|
-
const active = this.activeExecution;
|
|
431
|
-
if (!active || active.settled) return;
|
|
432
|
-
this.appendOutput(active, name, text);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
private appendOutput(active: ActiveExecution, name: "stdout" | "stderr", text: string): void {
|
|
436
|
-
if (active.abortRequested) return;
|
|
437
|
-
const key = name === "stdout" ? "stdout" : "stderr";
|
|
438
|
-
const truncatedKey = name === "stdout" ? "stdoutTruncated" : "stderrTruncated";
|
|
439
|
-
if (active[key].length < active.maxChars) {
|
|
440
|
-
active[key] += text;
|
|
441
|
-
if (active[key].length > active.maxChars) {
|
|
442
|
-
active[key] = active[key].slice(0, active.maxChars);
|
|
443
|
-
active[truncatedKey] = true;
|
|
444
|
-
}
|
|
445
|
-
} else {
|
|
446
|
-
active[truncatedKey] = true;
|
|
447
|
-
}
|
|
448
|
-
// --- cap the live stream feed, so partial content can't grow past the output budget ---
|
|
449
|
-
const room = active.maxChars - active.streamedChars;
|
|
450
|
-
const forward = Math.min(text.length, Math.max(0, room));
|
|
451
|
-
if (forward > 0) active.opts.onStream?.(text.slice(0, forward), name);
|
|
452
|
-
active.streamedChars += forward;
|
|
184
|
+
await this.kernel?.shutdown();
|
|
185
|
+
this.killSync();
|
|
453
186
|
}
|
|
454
187
|
|
|
455
|
-
//--- execute ---
|
|
456
|
-
|
|
457
188
|
async execute(code: string, opts: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
458
189
|
// --- claim the queue slot synchronously so order == submission order ---
|
|
459
190
|
const previous = this.executionQueue;
|
|
@@ -467,130 +198,69 @@ export class EngineManager {
|
|
|
467
198
|
if (opts.signal?.aborted) {
|
|
468
199
|
return { stdout: "", stderr: "", status: "aborted", durationMs: 0 };
|
|
469
200
|
}
|
|
470
|
-
if (this.
|
|
201
|
+
if (this.isShutdown()) {
|
|
471
202
|
throw new Error("Engine has been shut down");
|
|
472
203
|
}
|
|
473
204
|
await this.start();
|
|
474
|
-
if (
|
|
205
|
+
if (this.isShutdown()) {
|
|
475
206
|
throw new Error("Engine has been shut down");
|
|
476
207
|
}
|
|
477
|
-
if (this.maybeWedged) {
|
|
478
|
-
await this.assertGuestResponsive();
|
|
479
|
-
}
|
|
480
|
-
const result = await this.executeInner(code, opts);
|
|
481
|
-
if (result.status === "ok") this.scheduleSnapshot();
|
|
482
|
-
return result;
|
|
483
|
-
} finally {
|
|
484
|
-
release();
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
private async assertGuestResponsive(): Promise<void> {
|
|
489
|
-
try {
|
|
490
|
-
await this.request({ type: "ping", id: randomUUID() }, PING_TIMEOUT_MS);
|
|
491
|
-
this.maybeWedged = false;
|
|
492
|
-
} catch (error) {
|
|
493
|
-
if (this.state === "shutdown" || !this.child) {
|
|
494
|
-
throw new Error("Engine has been shut down");
|
|
495
|
-
}
|
|
496
|
-
void error;
|
|
497
|
-
throw new EngineBusyError();
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
private executeInner(code: string, opts: ExecuteOptions): Promise<ExecuteResult> {
|
|
502
|
-
const cellId = randomUUID();
|
|
503
|
-
const started = Date.now();
|
|
504
|
-
|
|
505
|
-
return new Promise<ExecuteResult>((resolve, reject) => {
|
|
506
|
-
const active: ActiveExecution = {
|
|
507
|
-
cellId,
|
|
508
|
-
code,
|
|
509
|
-
started,
|
|
510
|
-
maxChars: opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS,
|
|
511
|
-
opts,
|
|
512
|
-
stdout: "",
|
|
513
|
-
stderr: "",
|
|
514
|
-
stdoutTruncated: false,
|
|
515
|
-
stderrTruncated: false,
|
|
516
|
-
status: "ok",
|
|
517
|
-
settled: false,
|
|
518
|
-
abortRequested: false,
|
|
519
|
-
streamedChars: 0,
|
|
520
|
-
hostAbort: new AbortController(),
|
|
521
|
-
resolve,
|
|
522
|
-
reject,
|
|
523
|
-
};
|
|
524
|
-
this.activeExecution = active;
|
|
525
208
|
|
|
209
|
+
const started = Date.now();
|
|
210
|
+
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
211
|
+
let aborted = false;
|
|
526
212
|
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
527
213
|
const onAbort = () => {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
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 ---
|
|
532
217
|
graceTimer = setTimeout(() => {
|
|
533
|
-
if (this.
|
|
534
|
-
|
|
535
|
-
this.
|
|
218
|
+
if (this.state === "running" && this.kernel) {
|
|
219
|
+
this.state = "shutdown";
|
|
220
|
+
this.kernel.kill();
|
|
221
|
+
this.kernel = undefined;
|
|
536
222
|
}
|
|
537
223
|
}, ABORT_GRACE_MS);
|
|
538
224
|
graceTimer.unref?.();
|
|
539
225
|
};
|
|
540
226
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
541
227
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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 {
|
|
550
251
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
551
252
|
if (graceTimer) clearTimeout(graceTimer);
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
this.sendToGuest({ type: "run", cellId, code });
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
private settleActiveExecution(active: ActiveExecution): void {
|
|
560
|
-
if (active.settled) return;
|
|
561
|
-
active.settled = true;
|
|
562
|
-
if (this.activeExecution === active) this.activeExecution = undefined;
|
|
563
|
-
|
|
564
|
-
// --- a cancelled cell reports "aborted" even if it finished first (caller withdrew) ---
|
|
565
|
-
let status = active.status;
|
|
566
|
-
if (active.opts.signal?.aborted) status = "aborted";
|
|
567
|
-
if (status !== "aborted") this.maybeWedged = false;
|
|
568
|
-
|
|
569
|
-
const stdout = truncateWithMarker(active.stdout, active.maxChars, active.stdoutTruncated);
|
|
570
|
-
const stderr = truncateWithMarker(active.stderr, active.maxChars, active.stderrTruncated);
|
|
571
|
-
let result = active.result;
|
|
572
|
-
if (result != null && String(result).length > active.maxChars) {
|
|
573
|
-
result = truncateWithMarker(String(result), active.maxChars, true);
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
release();
|
|
574
256
|
}
|
|
575
|
-
|
|
576
|
-
active.resolve({
|
|
577
|
-
stdout,
|
|
578
|
-
stderr,
|
|
579
|
-
result,
|
|
580
|
-
error: active.error,
|
|
581
|
-
status,
|
|
582
|
-
durationMs: Date.now() - active.started,
|
|
583
|
-
});
|
|
584
257
|
}
|
|
585
258
|
|
|
586
|
-
//--- snapshot / restore / names ---
|
|
587
|
-
|
|
588
259
|
async snapshotState(): Promise<SnapshotResult | null> {
|
|
589
260
|
const config = this.options.snapshot;
|
|
590
|
-
if (!config || this.state !== "running") return null;
|
|
261
|
+
if (!config || this.state !== "running" || !this.kernel) return null;
|
|
591
262
|
try {
|
|
592
|
-
const reply = await this.
|
|
593
|
-
if (reply.type !== "snapshot_result") return null;
|
|
263
|
+
const reply = await this.kernel.snapshot();
|
|
594
264
|
// --- an incomplete snapshot must not overwrite the last good file ---
|
|
595
265
|
if (reply.complete === false) return null;
|
|
596
266
|
mkdirSync(dirname(config.path), { recursive: true });
|
|
@@ -607,12 +277,9 @@ export class EngineManager {
|
|
|
607
277
|
if (!existsSync(config.path)) return null;
|
|
608
278
|
await this.start();
|
|
609
279
|
try {
|
|
610
|
-
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
611
|
-
vars?: Record<string, string>;
|
|
612
|
-
};
|
|
280
|
+
const payload = JSON.parse(readFileSync(config.path, "utf8")) as { vars?: Record<string, string> };
|
|
613
281
|
const vars = payload.vars ?? {};
|
|
614
|
-
const reply = await this.
|
|
615
|
-
if (reply.type !== "restore_result") return null;
|
|
282
|
+
const reply = await this.kernel!.restore(vars);
|
|
616
283
|
return { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
617
284
|
} catch {
|
|
618
285
|
return null;
|
|
@@ -620,10 +287,9 @@ export class EngineManager {
|
|
|
620
287
|
}
|
|
621
288
|
|
|
622
289
|
async listNamespaceNames(): Promise<string[] | null> {
|
|
623
|
-
if (this.state !== "running") return null;
|
|
290
|
+
if (this.state !== "running" || !this.kernel) return null;
|
|
624
291
|
try {
|
|
625
|
-
|
|
626
|
-
return reply.type === "names_result" ? reply.names : null;
|
|
292
|
+
return await this.kernel.listNames();
|
|
627
293
|
} catch {
|
|
628
294
|
return null;
|
|
629
295
|
}
|