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
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// --- KernelClient: one ipykernel subprocess driven directly over ZMTP (no guest middleman). ---
|
|
2
|
+
|
|
3
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
type ConnectionFile,
|
|
10
|
+
executeRequest,
|
|
11
|
+
JupyterSession,
|
|
12
|
+
NAMES_MIME,
|
|
13
|
+
type ParsedMessage,
|
|
14
|
+
RESTORE_MIME,
|
|
15
|
+
readConnectionFile,
|
|
16
|
+
readPayload,
|
|
17
|
+
SNAPSHOT_MIME,
|
|
18
|
+
} from "./session.js";
|
|
19
|
+
import { ZmtpSocket } from "./zmtp.js";
|
|
20
|
+
|
|
21
|
+
const KERNEL_READY_TIMEOUT_MS = 30_000;
|
|
22
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000;
|
|
23
|
+
|
|
24
|
+
export interface KernelOptions {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
/** Silence watchdog in ms; 0 = no cap (a silent-but-working cell may run on). */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CellResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
result?: string;
|
|
35
|
+
error?: { name: string; message: string; stack: string[] };
|
|
36
|
+
status: "ok" | "error" | "aborted";
|
|
37
|
+
/** Per-channel output was capped; the host adds a truncation marker. */
|
|
38
|
+
truncated?: { stdout: boolean; stderr: boolean };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CellOptions {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
44
|
+
/** Cap per-channel output accumulation. Default 1 MiB (the old guest cap). */
|
|
45
|
+
maxOutputChars?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SnapshotReply {
|
|
49
|
+
vars: Record<string, string>;
|
|
50
|
+
failed: { name: string; reason: string }[];
|
|
51
|
+
complete: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- boot preload: exec each helper; ls()/help() are gone, discovery is globals() ---
|
|
55
|
+
|
|
56
|
+
/** Read the helpers dir (same skip rules as the extension's prompt loader). */
|
|
57
|
+
export function readHelperSources(dir?: string): { name: string; source: string }[] {
|
|
58
|
+
// --- one fixed dir, resolved like the prompt side (helpers.ts) so both always agree ---
|
|
59
|
+
const d = dir ?? join(homedir(), ".pi", "agent", "pi-repl", "helpers");
|
|
60
|
+
if (!existsSync(d)) return [];
|
|
61
|
+
const out: { name: string; source: string }[] = [];
|
|
62
|
+
for (const file of readdirSync(d).sort()) {
|
|
63
|
+
if (!file.endsWith(".py")) continue;
|
|
64
|
+
const name = file.slice(0, -3);
|
|
65
|
+
if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith("_")) continue;
|
|
66
|
+
try {
|
|
67
|
+
out.push({ name, source: readFileSync(join(d, file), "utf8") });
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildSkipList(helperNames: string[]): string {
|
|
74
|
+
const names = new Set([...helperNames, "helper_description", "In", "Out", "get_ipython", "exit", "quit", "open"]);
|
|
75
|
+
return JSON.stringify([...names]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function snapshotCode(helperNames: string[]): string {
|
|
79
|
+
const skip = buildSkipList(helperNames);
|
|
80
|
+
return (
|
|
81
|
+
"import pickle as _pk, base64 as _b64, json as _js\n" +
|
|
82
|
+
`__repl_skip = set(${skip})\n` +
|
|
83
|
+
"__repl_v = {}\n__repl_f = []\n" +
|
|
84
|
+
"for _k, _v in list(globals().items()):\n" +
|
|
85
|
+
" if _k.startswith('_') or _k in __repl_skip:\n" +
|
|
86
|
+
" continue\n" +
|
|
87
|
+
" try:\n" +
|
|
88
|
+
" __repl_v[_k] = _b64.b64encode(_pk.dumps(_v)).decode()\n" +
|
|
89
|
+
" except Exception as _e:\n" +
|
|
90
|
+
" __repl_f.append({'name': _k, 'reason': str(_e)})\n" +
|
|
91
|
+
`get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'vars': __repl_v, 'failed': __repl_f})})\n`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function restoreCode(vars_: Record<string, string>): string {
|
|
96
|
+
const entries = Object.entries(vars_)
|
|
97
|
+
.map(([name, b64]) => {
|
|
98
|
+
const n = JSON.stringify(name);
|
|
99
|
+
return (
|
|
100
|
+
`try:\n globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(b64)}))\n __repl_r['restored'].append(${n})\n` +
|
|
101
|
+
`except Exception as _e:\n __repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`
|
|
102
|
+
);
|
|
103
|
+
})
|
|
104
|
+
.join("\n");
|
|
105
|
+
return (
|
|
106
|
+
"import pickle as _pk, base64 as _b64, json as _js\n" +
|
|
107
|
+
"__repl_r = {'restored': [], 'failed': []}\n" +
|
|
108
|
+
entries +
|
|
109
|
+
`\nget_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})\n`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function namesCode(helperNames: string[]): string {
|
|
114
|
+
const skip = buildSkipList(helperNames);
|
|
115
|
+
return (
|
|
116
|
+
"import json as _js\n" +
|
|
117
|
+
`__repl_skip = set(${skip})\n` +
|
|
118
|
+
"__repl_n = sorted(n for n in globals() if not n.startswith('_') and n not in __repl_skip)\n" +
|
|
119
|
+
`get_ipython().display_pub.publish({${JSON.stringify(NAMES_MIME)}: _js.dumps(__repl_n)})\n`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ActiveCell {
|
|
124
|
+
msgId: string;
|
|
125
|
+
stdout: string[];
|
|
126
|
+
stderr: string[];
|
|
127
|
+
outLen: number;
|
|
128
|
+
errLen: number;
|
|
129
|
+
maxChars: number;
|
|
130
|
+
result?: string;
|
|
131
|
+
error?: { name: string; message: string; stack: string[] };
|
|
132
|
+
status: CellResult["status"];
|
|
133
|
+
/** Private-MIME payloads published by this cell (snapshot/restore/names). */
|
|
134
|
+
payloads: Record<string, string>;
|
|
135
|
+
lastActivity: number;
|
|
136
|
+
timedOut: boolean;
|
|
137
|
+
stdoutTruncated: boolean;
|
|
138
|
+
stderrTruncated: boolean;
|
|
139
|
+
signal?: AbortSignal;
|
|
140
|
+
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
141
|
+
resolve(result: CellResult & { payloads: Record<string, string> }): void;
|
|
142
|
+
reject(error: Error): void;
|
|
143
|
+
settled: boolean;
|
|
144
|
+
/** The shell execute_reply arrived (carries the authoritative status). */
|
|
145
|
+
replySeen: boolean;
|
|
146
|
+
/** The matching iopub status idle arrived (published after all output). */
|
|
147
|
+
idleSeen: boolean;
|
|
148
|
+
/** The execute_reply content, held until both halves are seen. */
|
|
149
|
+
reply?: ParsedMessage;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class KernelClient {
|
|
153
|
+
private child?: ChildProcess;
|
|
154
|
+
private shell?: ZmtpSocket;
|
|
155
|
+
private control?: ZmtpSocket;
|
|
156
|
+
private iopub?: ZmtpSocket;
|
|
157
|
+
private readonly session: JupyterSession;
|
|
158
|
+
private readonly helperSources: { name: string; source: string }[];
|
|
159
|
+
private readonly timeoutMs: number;
|
|
160
|
+
private activeCell?: ActiveCell;
|
|
161
|
+
private connectionFilePath?: string;
|
|
162
|
+
private ready = false;
|
|
163
|
+
/** Serializes all kernel ops: one execute at a time, snapshots between cells. */
|
|
164
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
165
|
+
private onUnexpectedExit?: () => void;
|
|
166
|
+
private watchdog?: ReturnType<typeof setInterval>;
|
|
167
|
+
private pendingReplies = new Map<
|
|
168
|
+
string,
|
|
169
|
+
{ resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout> }
|
|
170
|
+
>();
|
|
171
|
+
|
|
172
|
+
private constructor(conn: ConnectionFile, opts: KernelOptions) {
|
|
173
|
+
this.session = new JupyterSession({ key: conn.key });
|
|
174
|
+
this.helperSources = readHelperSources(opts.env?.PI_HELPERS_DIR);
|
|
175
|
+
this.timeoutMs = opts.timeoutMs ?? 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Spawn ipykernel, connect all channels, and wait until it answers. */
|
|
179
|
+
static async start(pythonPath: string, opts: KernelOptions = {}): Promise<KernelClient> {
|
|
180
|
+
const connPath = join(tmpdir(), `pi-repl-kernel-${randomUUID()}.json`);
|
|
181
|
+
const child = spawn(pythonPath, ["-m", "ipykernel", "-f", connPath, "--no-stdout"], {
|
|
182
|
+
cwd: opts.cwd,
|
|
183
|
+
env: { ...process.env, ...(opts.env ?? {}) },
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
});
|
|
186
|
+
// --- ipykernel writes the connection file, then serves; keep stderr for post-mortems ---
|
|
187
|
+
let stderrTail = "";
|
|
188
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
189
|
+
stderrTail = (stderrTail + b.toString()).slice(-4000);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const deadline = Date.now() + KERNEL_READY_TIMEOUT_MS;
|
|
193
|
+
// --- poll until present AND fully written: existsSync fires before the write finishes ---
|
|
194
|
+
let conn: ConnectionFile;
|
|
195
|
+
while (true) {
|
|
196
|
+
if (child.exitCode !== null) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`ipykernel exited before writing its connection file (code=${child.exitCode})` +
|
|
199
|
+
(stderrTail ? `\nkernel stderr:\n${stderrTail}` : ""),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (Date.now() > deadline) {
|
|
203
|
+
child.kill("SIGKILL");
|
|
204
|
+
throw new Error("ipykernel did not write a valid connection file in time");
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
conn = readConnectionFile(connPath);
|
|
208
|
+
break;
|
|
209
|
+
} catch {
|
|
210
|
+
// --- not present yet, or mid-write: retry ---
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const kc = new KernelClient(conn, opts);
|
|
216
|
+
kc.child = child;
|
|
217
|
+
kc.connectionFilePath = connPath;
|
|
218
|
+
child.on("exit", () => {
|
|
219
|
+
// --- a dead kernel settles the running cell; the engine rebuilds ---
|
|
220
|
+
kc.settleActive(new Error("kernel process exited"));
|
|
221
|
+
kc.onUnexpectedExit?.();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await kc.connectChannels(conn);
|
|
226
|
+
await kc.probeReady();
|
|
227
|
+
await kc.preload();
|
|
228
|
+
kc.ready = true;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
kc.kill();
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
return kc;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async connectChannels(conn: ConnectionFile): Promise<void> {
|
|
237
|
+
if (conn.transport !== "tcp") {
|
|
238
|
+
throw new Error(`unsupported kernel transport "${conn.transport}" (only tcp) — set PI_KERNEL_TRANSPORT=tcp`);
|
|
239
|
+
}
|
|
240
|
+
const [shell, control, iopub] = await Promise.all([
|
|
241
|
+
ZmtpSocket.connect({ host: conn.ip, port: conn.shell_port, socketType: "DEALER" }),
|
|
242
|
+
ZmtpSocket.connect({ host: conn.ip, port: conn.control_port, socketType: "DEALER" }),
|
|
243
|
+
ZmtpSocket.connect({ host: conn.ip, port: conn.iopub_port, socketType: "SUB" }),
|
|
244
|
+
]);
|
|
245
|
+
this.shell = shell;
|
|
246
|
+
this.control = control;
|
|
247
|
+
this.iopub = iopub;
|
|
248
|
+
shell.onMessage = (frames) => this.onShellMessage(frames);
|
|
249
|
+
control.onMessage = (frames) => this.onControlMessage(frames);
|
|
250
|
+
iopub.onMessage = (frames) => this.onIopubMessage(frames);
|
|
251
|
+
iopub.subscribe(Buffer.from([])); // all traffic
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private probeReady(): Promise<void> {
|
|
255
|
+
const msgId = this.session.nextMsgId();
|
|
256
|
+
this.shell?.send(this.session.buildFrames("kernel_info_request", {}, null, msgId));
|
|
257
|
+
return this.waitForReply(msgId, KERNEL_READY_TIMEOUT_MS, "kernel_info_reply").then(() => {});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Exec every helper file into the kernel namespace. No custom intrinsics. */
|
|
261
|
+
private preload(): Promise<void> {
|
|
262
|
+
let code = "";
|
|
263
|
+
for (const h of this.helperSources) code += `\n${h.source}\n`;
|
|
264
|
+
return this.executeCell(code, { maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS }).then(() => {});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private onShellMessage(frames: Buffer[]): void {
|
|
268
|
+
const msg = this.session.parseMessage(frames);
|
|
269
|
+
if (!msg) return;
|
|
270
|
+
const active = this.activeCell;
|
|
271
|
+
if (msg.msg_type === "execute_reply" && active && msg.parent.msg_id === active.msgId) {
|
|
272
|
+
// --- the shell reply races the iopub stream: record it, settle only after idle ---
|
|
273
|
+
active.reply = msg;
|
|
274
|
+
active.replySeen = true;
|
|
275
|
+
this.maybeSettle(active);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (msg.msg_type === "kernel_info_reply" || msg.msg_type === "execute_reply") this.resolveReply(msg);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private onControlMessage(frames: Buffer[]): void {
|
|
282
|
+
const msg = this.session.parseMessage(frames);
|
|
283
|
+
if (!msg) return;
|
|
284
|
+
// interrupt_reply / shutdown_reply — nothing awaits them; keep draining.
|
|
285
|
+
this.resolveReply(msg);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private onIopubMessage(frames: Buffer[]): void {
|
|
289
|
+
const msg = this.session.parseMessage(frames);
|
|
290
|
+
if (!msg) return;
|
|
291
|
+
const active = this.activeCell;
|
|
292
|
+
if (!active || msg.parent.msg_id !== active.msgId) return;
|
|
293
|
+
active.lastActivity = Date.now();
|
|
294
|
+
const c = msg.content;
|
|
295
|
+
switch (msg.msg_type) {
|
|
296
|
+
case "stream": {
|
|
297
|
+
const text = (c.text as string) ?? "";
|
|
298
|
+
this.accumulate(active, c.name === "stderr" ? "stderr" : "stdout", text);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "execute_result": {
|
|
302
|
+
const data = c.data as Record<string, unknown> | undefined;
|
|
303
|
+
const plain = data?.["text/plain"];
|
|
304
|
+
if (typeof plain === "string") active.result = plain;
|
|
305
|
+
this.collectPayload(active, c);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "display_data":
|
|
309
|
+
this.collectPayload(active, c);
|
|
310
|
+
break;
|
|
311
|
+
case "error": {
|
|
312
|
+
const traceback = Array.isArray(c.traceback) ? (c.traceback as string[]) : [];
|
|
313
|
+
active.error = {
|
|
314
|
+
name: (c.ename as string) ?? "Error",
|
|
315
|
+
message: (c.evalue as string) ?? traceback.join("\n"),
|
|
316
|
+
stack: traceback,
|
|
317
|
+
};
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case "status":
|
|
321
|
+
// --- status idle is published after every byte; the cell is complete only once we have it ---
|
|
322
|
+
if (c.execution_state === "idle") {
|
|
323
|
+
active.idleSeen = true;
|
|
324
|
+
this.maybeSettle(active);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private collectPayload(active: ActiveCell, content: Record<string, unknown>): void {
|
|
331
|
+
for (const mime of [SNAPSHOT_MIME, RESTORE_MIME, NAMES_MIME]) {
|
|
332
|
+
const payload = readPayload(content, mime);
|
|
333
|
+
if (payload !== null) {
|
|
334
|
+
active.payloads[mime] = payload;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private accumulate(active: ActiveCell, name: "stdout" | "stderr", text: string): void {
|
|
341
|
+
const arr = name === "stdout" ? active.stdout : active.stderr;
|
|
342
|
+
const len = name === "stdout" ? active.outLen : active.errLen;
|
|
343
|
+
const room = active.maxChars - len;
|
|
344
|
+
const keep = Math.min(text.length, Math.max(0, room));
|
|
345
|
+
if (keep > 0) {
|
|
346
|
+
arr.push(text.slice(0, keep));
|
|
347
|
+
if (name === "stdout") active.outLen += keep;
|
|
348
|
+
else active.errLen += keep;
|
|
349
|
+
}
|
|
350
|
+
// --- one oversized stream frame (10 MB print) overflows the cap within this call ---
|
|
351
|
+
if (text.length > keep) {
|
|
352
|
+
if (name === "stdout") active.stdoutTruncated = true;
|
|
353
|
+
else active.stderrTruncated = true;
|
|
354
|
+
}
|
|
355
|
+
// --- beyond the cap we drop text but keep draining ---
|
|
356
|
+
active.onStream?.(text.slice(0, keep), name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private waitForReply(msgId: string, timeoutMs: number, expectedType: string): Promise<ParsedMessage> {
|
|
360
|
+
return new Promise<ParsedMessage>((resolve, reject) => {
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
this.pendingReplies.delete(msgId);
|
|
363
|
+
reject(new Error(`kernel did not answer ${expectedType} in time`));
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
timer.unref?.();
|
|
366
|
+
this.pendingReplies.set(msgId, { resolve, timer });
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private resolveReply(msg: ParsedMessage): void {
|
|
371
|
+
const pending = this.pendingReplies.get(msg.parent.msg_id as string);
|
|
372
|
+
if (!pending) return;
|
|
373
|
+
this.pendingReplies.delete(msg.parent.msg_id as string);
|
|
374
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
375
|
+
pending.resolve(msg);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
executeCell(code: string, opts: CellOptions = {}): Promise<CellResult> {
|
|
379
|
+
return this.enqueue(() => this.executeCellNow(code, opts)).then(({ payloads: _payloads, ...rest }) => rest);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
383
|
+
const result = this.queue.then(fn, fn);
|
|
384
|
+
this.queue = result.catch(() => {});
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private executeCellNow(code: string, opts: CellOptions): Promise<CellResult & { payloads: Record<string, string> }> {
|
|
389
|
+
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
390
|
+
// --- one msg_id per request; the kernel echoes it as the reply's parent for routing ---
|
|
391
|
+
const msgId = this.session.nextMsgId();
|
|
392
|
+
const active: ActiveCell = {
|
|
393
|
+
msgId,
|
|
394
|
+
stdout: [],
|
|
395
|
+
stderr: [],
|
|
396
|
+
outLen: 0,
|
|
397
|
+
errLen: 0,
|
|
398
|
+
maxChars,
|
|
399
|
+
payloads: {},
|
|
400
|
+
lastActivity: Date.now(),
|
|
401
|
+
timedOut: false,
|
|
402
|
+
stdoutTruncated: false,
|
|
403
|
+
stderrTruncated: false,
|
|
404
|
+
signal: opts.signal,
|
|
405
|
+
onStream: opts.onStream,
|
|
406
|
+
status: "ok",
|
|
407
|
+
settled: false,
|
|
408
|
+
replySeen: false,
|
|
409
|
+
idleSeen: false,
|
|
410
|
+
resolve: () => {},
|
|
411
|
+
reject: () => {},
|
|
412
|
+
};
|
|
413
|
+
this.activeCell = active;
|
|
414
|
+
const onAbort = () => this.interrupt();
|
|
415
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
416
|
+
|
|
417
|
+
this.shell?.send(this.session.buildFrames("execute_request", executeRequest(code, false), null, msgId));
|
|
418
|
+
this.startWatchdog(active);
|
|
419
|
+
|
|
420
|
+
return new Promise<CellResult & { payloads: Record<string, string> }>((resolve, reject) => {
|
|
421
|
+
active.resolve = (result) => resolve(result);
|
|
422
|
+
active.reject = reject;
|
|
423
|
+
if (opts.signal?.aborted) this.interrupt();
|
|
424
|
+
}).finally(() => {
|
|
425
|
+
if (this.activeCell === active) this.activeCell = undefined;
|
|
426
|
+
this.stopWatchdog();
|
|
427
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private settleActive(error: Error): void {
|
|
432
|
+
const active = this.activeCell;
|
|
433
|
+
if (!active || active.settled) return;
|
|
434
|
+
active.settled = true;
|
|
435
|
+
active.reject(error);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private maybeSettle(active: ActiveCell): void {
|
|
439
|
+
// --- settle only once the shell reply AND the idle iopub stream arrive, else output is dropped ---
|
|
440
|
+
if (active.settled || !active.replySeen || !active.idleSeen) return;
|
|
441
|
+
this.settleFromReply(active, active.reply!);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private settleFromReply(active: ActiveCell, msg: ParsedMessage): void {
|
|
445
|
+
if (active.settled) return;
|
|
446
|
+
active.settled = true;
|
|
447
|
+
const content = msg.content;
|
|
448
|
+
const replyStatus = (content.status as string) ?? "ok";
|
|
449
|
+
if (replyStatus === "error" && !active.error) {
|
|
450
|
+
active.error = {
|
|
451
|
+
name: (content.ename as string) ?? "Error",
|
|
452
|
+
message: (content.evalue as string) ?? "error",
|
|
453
|
+
stack: [],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
let status: CellResult["status"] = replyStatus === "aborted" ? "aborted" : active.error ? "error" : "ok";
|
|
457
|
+
if (active.signal?.aborted) {
|
|
458
|
+
// --- the caller withdrew; report aborted even if the cell raised ---
|
|
459
|
+
status = "aborted";
|
|
460
|
+
} else if (active.timedOut) {
|
|
461
|
+
// --- silence watchdog tripped: the cell was still running, not done ---
|
|
462
|
+
status = "error";
|
|
463
|
+
active.error = {
|
|
464
|
+
name: "Timeout",
|
|
465
|
+
message: "cell did not finish within the silence window and may still be running",
|
|
466
|
+
stack: ["[cell timed out]"],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
active.status = status;
|
|
470
|
+
active.resolve({
|
|
471
|
+
stdout: active.stdout.join(""),
|
|
472
|
+
stderr: active.stderr.join(""),
|
|
473
|
+
result: active.result,
|
|
474
|
+
error: active.error,
|
|
475
|
+
status,
|
|
476
|
+
truncated: { stdout: active.stdoutTruncated, stderr: active.stderrTruncated },
|
|
477
|
+
payloads: active.payloads,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private startWatchdog(active: ActiveCell): void {
|
|
482
|
+
this.stopWatchdog();
|
|
483
|
+
if (!this.timeoutMs) return;
|
|
484
|
+
this.watchdog = setInterval(
|
|
485
|
+
() => {
|
|
486
|
+
const quiet = Date.now() - active.lastActivity;
|
|
487
|
+
if (quiet >= this.timeoutMs && !active.settled) {
|
|
488
|
+
active.timedOut = true;
|
|
489
|
+
this.interrupt();
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
Math.min(250, this.timeoutMs),
|
|
493
|
+
);
|
|
494
|
+
this.watchdog.unref?.();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private stopWatchdog(): void {
|
|
498
|
+
if (this.watchdog) {
|
|
499
|
+
clearInterval(this.watchdog);
|
|
500
|
+
this.watchdog = undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Genuine KeyboardInterrupt via control-channel interrupt_request; the kernel survives. */
|
|
505
|
+
interrupt(): void {
|
|
506
|
+
const active = this.activeCell;
|
|
507
|
+
if (!active || active.settled) return;
|
|
508
|
+
this.control?.send(this.session.buildFrames("interrupt_request", {}, null));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
snapshot(): Promise<SnapshotReply> {
|
|
512
|
+
return this.enqueue(async () => {
|
|
513
|
+
const res = await this.executeCellNow(snapshotCode(this.helperSources.map((h) => h.name)), {
|
|
514
|
+
maxOutputChars: 8_000_000,
|
|
515
|
+
});
|
|
516
|
+
const payload = res.payloads[SNAPSHOT_MIME];
|
|
517
|
+
if (payload === undefined) return { vars: {}, failed: [], complete: false };
|
|
518
|
+
try {
|
|
519
|
+
const obj = JSON.parse(payload) as {
|
|
520
|
+
vars?: Record<string, string>;
|
|
521
|
+
failed?: { name: string; reason: string }[];
|
|
522
|
+
};
|
|
523
|
+
return { vars: obj.vars ?? {}, failed: obj.failed ?? [], complete: true };
|
|
524
|
+
} catch {
|
|
525
|
+
return { vars: {}, failed: [], complete: false };
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
restore(vars_: Record<string, string>): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
|
|
531
|
+
if (Object.keys(vars_).length === 0) return Promise.resolve({ restored: [], failed: [] });
|
|
532
|
+
return this.enqueue(async () => {
|
|
533
|
+
const res = await this.executeCellNow(restoreCode(vars_), { maxOutputChars: 8_000_000 });
|
|
534
|
+
const payload = res.payloads[RESTORE_MIME];
|
|
535
|
+
if (payload === undefined) return { restored: [], failed: [] };
|
|
536
|
+
try {
|
|
537
|
+
const obj = JSON.parse(payload) as { restored?: string[]; failed?: { name: string; reason: string }[] };
|
|
538
|
+
return { restored: obj.restored ?? [], failed: obj.failed ?? [] };
|
|
539
|
+
} catch {
|
|
540
|
+
return { restored: [], failed: [] };
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
listNames(): Promise<string[]> {
|
|
546
|
+
return this.enqueue(async () => {
|
|
547
|
+
const res = await this.executeCellNow(namesCode(this.helperSources.map((h) => h.name)), {
|
|
548
|
+
maxOutputChars: 8_000_000,
|
|
549
|
+
});
|
|
550
|
+
const payload = res.payloads[NAMES_MIME];
|
|
551
|
+
if (payload === undefined) return [];
|
|
552
|
+
try {
|
|
553
|
+
const arr = JSON.parse(payload) as unknown;
|
|
554
|
+
return Array.isArray(arr) ? (arr as string[]) : [];
|
|
555
|
+
} catch {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Graceful stop: shutdown_request on control, then SIGKILL as backstop. */
|
|
562
|
+
async shutdown(): Promise<void> {
|
|
563
|
+
const child = this.child;
|
|
564
|
+
if (this.ready && this.control && child && child.exitCode === null) {
|
|
565
|
+
this.control.send(this.session.buildFrames("shutdown_request", { restart: false }, null));
|
|
566
|
+
await Promise.race([this.childExit(), new Promise((resolve) => setTimeout(resolve, 2000).unref?.())]).catch(
|
|
567
|
+
() => {},
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
this.kill();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private childExit(): Promise<void> {
|
|
574
|
+
const child = this.child;
|
|
575
|
+
if (!child) return Promise.resolve();
|
|
576
|
+
return child.exitCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
kill(): void {
|
|
580
|
+
this.stopWatchdog();
|
|
581
|
+
this.settleActive(new Error("kernel killed"));
|
|
582
|
+
this.shell?.close();
|
|
583
|
+
this.control?.close();
|
|
584
|
+
this.iopub?.close();
|
|
585
|
+
this.child?.kill("SIGKILL");
|
|
586
|
+
this.child = undefined;
|
|
587
|
+
if (this.connectionFilePath) {
|
|
588
|
+
try {
|
|
589
|
+
rmSync(this.connectionFilePath, { force: true });
|
|
590
|
+
} catch {}
|
|
591
|
+
this.connectionFilePath = undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
get isRunning(): boolean {
|
|
596
|
+
return this.ready && this.child !== undefined;
|
|
597
|
+
}
|
|
598
|
+
}
|