pi-repl-py 0.7.1 → 0.8.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.
@@ -1,32 +1,23 @@
1
+ // --- KernelClient over one stdio pipe: bridge.py owns ipykernel and the Jupyter protocol;
2
+ // --- this side spawns it, speaks one JSON line at a time, and applies the output caps. ---
3
+
1
4
  import { type ChildProcess, spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
6
  import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
6
8
  import { resolveHelperDirs } from "./helpers-locate.js";
7
- import {
8
- type ConnectionFile,
9
- executeRequest,
10
- isTrustedMessage,
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;
9
+
22
10
  const SILENCE_KILL_GRACE_MS = 2000;
23
- const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000;
11
+ /** The bridge ships at the package root, next to index.ts; src/engine is two levels deep. */
12
+ const BRIDGE_PATH = fileURLToPath(new URL("../../bridge.py", import.meta.url));
24
13
 
25
14
  export interface KernelOptions {
26
15
  cwd?: string;
27
16
  env?: Record<string, string>;
28
17
  /** Silence watchdog in ms; 0 = no cap (a silent-but-working cell may run on). */
29
18
  timeoutMs?: number;
19
+ /** When present, the bridge self-schedules snapshots in its own quiet gaps. */
20
+ snapshot?: { path: string; max_bytes: number; period_ms: number };
30
21
  }
31
22
 
32
23
  export interface CellResult {
@@ -41,20 +32,19 @@ export interface CellResult {
41
32
  export interface CellOptions {
42
33
  signal?: AbortSignal;
43
34
  onStream?: (chunk: string, name: "stdout" | "stderr") => void;
44
- maxOutputChars?: number;
45
- }
46
-
47
- export interface SnapshotEntry {
48
- name: string;
49
- /** "value" = zlib-compressed pickle (v3 files; v2 are plain); "def" re-executes captured source (functions and classes). */
50
- kind: "value" | "def";
51
- payload: string;
35
+ /** The engine owns cap policy (DEFAULT_MAX_OUTPUT_CHARS); the kernel is transport and must not default its own. */
36
+ maxOutputChars: number;
52
37
  }
53
38
 
54
39
  export interface SnapshotReply {
55
- entries: SnapshotEntry[];
40
+ /** Names persisted; payloads never cross the pipe — the bridge wrote the file itself. */
41
+ saved?: string[];
42
+ /** Only present for tests that fake the kernel; the real bridge replies with counts only. */
43
+ entries?: { name: string; kind: "value" | "def"; payload: string }[];
56
44
  failed: { name: string; reason: string }[];
57
45
  complete: boolean;
46
+ /** Payload bytes written; drives the periodic-refresh stand-down. */
47
+ bytes?: number;
58
48
  }
59
49
 
60
50
  export interface HelperLoadResult {
@@ -64,12 +54,46 @@ export interface HelperLoadResult {
64
54
  error?: string;
65
55
  }
66
56
 
67
- /** Read the helpers dir (same skip rules as the extension's prompt loader). */
57
+ interface BridgeMessage {
58
+ type?: string;
59
+ id?: string;
60
+ [key: string]: unknown;
61
+ }
62
+
63
+ interface ActiveCell {
64
+ id: string;
65
+ stdout: string[];
66
+ stderr: string[];
67
+ outLen: number;
68
+ errLen: number;
69
+ maxChars: number;
70
+ result?: string;
71
+ error?: { name: string; message: string; stack: string[] };
72
+ status: CellResult["status"];
73
+ lastActivity: number;
74
+ timedOut: boolean;
75
+ stdoutTruncated: boolean;
76
+ stderrTruncated: boolean;
77
+ signal?: AbortSignal;
78
+ onStream?: (chunk: string, name: "stdout" | "stderr") => void;
79
+ resolve(result: CellResult): void;
80
+ reject(error: Error): void;
81
+ settled: boolean;
82
+ }
83
+
84
+ interface PendingRequest {
85
+ resolve(msg: BridgeMessage): void;
86
+ reject(error: Error): void;
87
+ timer?: ReturnType<typeof setTimeout>;
88
+ }
89
+
68
90
  /** Kernel cwd falls back to the host cwd when the requested dir is gone (a deleted project is a real resume case). */
69
91
  function resolveCwd(requested?: string): string {
70
92
  if (requested && existsSync(requested)) return requested;
71
93
  return process.cwd();
72
94
  }
95
+
96
+ /** Read the helpers dir (same skip rules as the extension's prompt loader); sources travel in the boot line. */
73
97
  function readHelperSources(dirs: string[]): { name: string; source: string }[] {
74
98
  // --- merged dirs come pre-ordered (project first, global last); first-seen name wins ---
75
99
  const seen = new Set<string>();
@@ -90,427 +114,159 @@ function readHelperSources(dirs: string[]): { name: string; source: string }[] {
90
114
  return out;
91
115
  }
92
116
 
93
- function buildSkipList(helperNames: string[]): string {
94
- const names = new Set([...helperNames, "helper_description", "In", "Out", "get_ipython", "exit", "quit", "open"]);
95
- return JSON.stringify([...names]);
96
- }
97
-
98
- function snapshotCode(helperNames: string[], maxBytes: number): string {
99
- const skip = buildSkipList(helperNames);
100
- // --- defs/classes can't be pickled by reference: capture source and re-exec on restore; oversized entries are skipped by name ---
101
- return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, inspect as _in, linecache as _lc
102
- def _repl_class_source(_c):
103
- _m = getattr(_c, '__init__', None)
104
- if _m is None or not _in.isfunction(_m):
105
- for _v in vars(_c).values():
106
- if _in.isfunction(_v):
107
- _m = _v
108
- break
109
- if _m is None:
110
- raise ValueError('class has no member methods')
111
- _start = _m.__code__.co_firstlineno
112
- _all = _lc.getlines(_m.__code__.co_filename)
113
- if not _all:
114
- raise ValueError('source not in linecache')
115
- _ln = _start - 1
116
- while _ln > 0:
117
- _prev = _all[_ln - 1].lstrip()
118
- if _prev.startswith('class ') and _c.__name__ in _prev:
119
- break
120
- _ln -= 1
121
- if _ln == 0:
122
- raise ValueError('class header not found')
123
- _head = _ln - 1
124
- while _head > 0:
125
- _p = _all[_head - 1].lstrip()
126
- if _p == '' or _p.startswith('@'):
127
- _head -= 1
128
- else:
129
- break
130
- _indent = len(_all[_head]) - len(_all[_head].lstrip())
131
- _block = [_all[_head]]
132
- _j = _head + 1
133
- while _j < len(_all):
134
- _line = _all[_j]
135
- if _line.strip() == '':
136
- _block.append(_line)
137
- _j += 1
138
- continue
139
- if len(_line) - len(_line.lstrip()) > _indent:
140
- _block.append(_line)
141
- _j += 1
142
- else:
143
- break
144
- return ''.join(_block)
145
- __repl_skip = set(${skip})
146
- __repl_max = ${maxBytes}
147
- __repl_e = []
148
- __repl_f = []
149
- __repl_total = 0
150
- for _k, _v in list(globals().items()):
151
- if _k.startswith('_') or _k in __repl_skip:
152
- continue
153
- __repl_p = None
154
- __repl_kind = 'value'
155
- try:
156
- if _in.isfunction(_v):
157
- __repl_src = _in.getsource(_v)
158
- if __repl_src:
159
- __repl_p = _b64.b64encode(__repl_src.encode()).decode()
160
- __repl_kind = 'def'
161
- elif _in.isclass(_v):
162
- __repl_src = _repl_class_source(_v)
163
- if __repl_src:
164
- __repl_p = _b64.b64encode(__repl_src.encode()).decode()
165
- __repl_kind = 'def'
166
- except Exception:
167
- __repl_p = None
168
- __repl_kind = 'value'
169
- try:
170
- if __repl_p is None:
171
- __repl_p = _b64.b64encode(_zl.compress(_pk.dumps(_v), 1)).decode()
172
- __repl_b = len(__repl_p)
173
- if __repl_b > __repl_max:
174
- __repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
175
- elif __repl_total + __repl_b > __repl_max:
176
- __repl_f.append({'name': _k, 'reason': 'exceeds total snapshot cap'})
177
- else:
178
- __repl_e.append({'name': _k, 'kind': __repl_kind, 'payload': __repl_p})
179
- __repl_total += __repl_b
180
- except Exception as _e:
181
- __repl_f.append({'name': _k, 'reason': str(_e)})
182
- get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'version': 2, 'entries': __repl_e, 'failed': __repl_f})})`;
183
- }
184
-
185
- function restoreCode(entries: SnapshotEntry[], compressedValues: boolean): string {
186
- const per = entries
187
- .map(({ name, kind, payload }) => {
188
- const n = JSON.stringify(name);
189
- const body =
190
- kind === "def"
191
- ? // re-execute captured source and register it in linecache under the code
192
- // --- exec also registers the source in linecache so a later snapshot can capture it again ---
193
- `__repl_src = _b64.b64decode(${JSON.stringify(payload)}).decode()
194
- exec(__repl_src, globals())
195
- __repl_obj = globals().get(${n})
196
- if __repl_obj is not None:
197
- __repl_fname = getattr(getattr(__repl_obj, '__code__', None), 'co_filename', None)
198
- if __repl_fname is None:
199
- __repl_init = getattr(__repl_obj, '__init__', None)
200
- __repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)
201
- if __repl_fname:
202
- _lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)`
203
- : compressedValues
204
- ? `globals()[${n}] = _pk.loads(_zl.decompress(_b64.b64decode(${JSON.stringify(payload)})))`
205
- : `globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(payload)}))`;
206
- return `try:
207
- ${body}
208
- __repl_r['restored'].append(${n})
209
- except Exception as _e:
210
- __repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`;
211
- })
212
- .join("\n");
213
- return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, linecache as _lc
214
- __repl_r = {'restored': [], 'failed': []}
215
- ${per}
216
- get_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})`;
217
- }
218
-
219
- function namesCode(helperNames: string[]): string {
220
- const skip = buildSkipList(helperNames);
221
- return (
222
- "import json as _js\n" +
223
- `__repl_skip = set(${skip})\n` +
224
- "__repl_n = sorted(n for n in globals() if not n.startswith('_') and n not in __repl_skip)\n" +
225
- `get_ipython().display_pub.publish({${JSON.stringify(NAMES_MIME)}: _js.dumps(__repl_n)})\n`
226
- );
227
- }
228
-
229
- interface ActiveCell {
230
- msgId: string;
231
- stdout: string[];
232
- stderr: string[];
233
- outLen: number;
234
- errLen: number;
235
- maxChars: number;
236
- result?: string;
237
- error?: { name: string; message: string; stack: string[] };
238
- status: CellResult["status"];
239
- payloads: Record<string, string>;
240
- lastActivity: number;
241
- timedOut: boolean;
242
- stdoutTruncated: boolean;
243
- stderrTruncated: boolean;
244
- signal?: AbortSignal;
245
- onStream?: (chunk: string, name: "stdout" | "stderr") => void;
246
- resolve(result: CellResult & { payloads: Record<string, string> }): void;
247
- reject(error: Error): void;
248
- settled: boolean;
249
- replySeen: boolean;
250
- idleSeen: boolean;
251
- reply?: ParsedMessage;
252
- }
253
-
254
117
  export class KernelClient {
255
118
  private child?: ChildProcess;
256
- private shell?: ZmtpSocket;
257
- private control?: ZmtpSocket;
258
- private iopub?: ZmtpSocket;
259
- private readonly session: JupyterSession;
260
- private readonly helperSources: { name: string; source: string }[];
261
- private helperBootReport: HelperLoadResult[] = [];
262
119
  private readonly timeoutMs: number;
263
- private activeCell?: ActiveCell;
264
- private connectionFilePath?: string;
265
120
  private ready = false;
266
121
  /** Serializes all kernel ops: one execute at a time, snapshots between cells. */
267
122
  private queue: Promise<unknown> = Promise.resolve();
123
+ private pending = new Map<string, PendingRequest>();
124
+ private activeCell?: ActiveCell;
125
+ private inputBuffer = "";
126
+ private readyWaiters: { resolve(msg: BridgeMessage): void; reject(error: Error): void }[] = [];
127
+ private helperBootReport: HelperLoadResult[] = [];
128
+ private nextId = 0;
268
129
  private _onUnexpectedExit?: () => void;
269
- get helperReport(): readonly HelperLoadResult[] {
270
- return this.helperBootReport;
271
- }
272
-
273
- /** Engine hook: an unexpected kernel death (not a deliberate kill) should drop the instance. */
274
- setOnUnexpectedExit(fn: () => void): void {
275
- this._onUnexpectedExit = fn;
276
- }
277
130
  private watchdog?: ReturnType<typeof setInterval>;
278
131
  private silenceKillTimer?: ReturnType<typeof setTimeout>;
279
- private pendingReplies = new Map<
280
- string,
281
- { resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout>; expectedType: string }
282
- >();
283
-
284
- private constructor(conn: ConnectionFile, opts: KernelOptions) {
285
- this.session = new JupyterSession({ key: conn.key });
286
- this.helperSources = opts.env?.PI_HELPERS_DIR
287
- ? readHelperSources([opts.env.PI_HELPERS_DIR])
288
- : readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR));
132
+
133
+ private constructor(opts: KernelOptions) {
289
134
  this.timeoutMs = opts.timeoutMs ?? 0;
290
135
  }
291
136
 
292
137
  static async start(pythonPath: string, opts: KernelOptions = {}): Promise<KernelClient> {
293
- const connPath = join(tmpdir(), `pi-repl-kernel-${randomUUID()}.json`);
294
- const child = spawn(pythonPath, ["-m", "ipykernel", "-f", connPath, "--no-stdout"], {
138
+ const child = spawn(pythonPath, [BRIDGE_PATH], {
295
139
  cwd: resolveCwd(opts.cwd),
296
140
  env: { ...process.env, ...(opts.env ?? {}) },
297
- stdio: ["ignore", "pipe", "pipe"],
141
+ stdio: ["pipe", "pipe", "pipe"],
142
+ // its own process group: kill(-pid) reaches the kernel the bridge spawns too
143
+ detached: true,
298
144
  });
299
- // --- ipykernel writes the connection file, then serves; keep stderr for post-mortems ---
145
+ // keep stderr for post-mortems (ipykernel warnings, bridge tracebacks)
300
146
  let stderrTail = "";
301
147
  child.stderr?.on("data", (b: Buffer) => {
302
148
  stderrTail = (stderrTail + b.toString()).slice(-4000);
303
149
  });
304
150
 
305
- const deadline = Date.now() + KERNEL_READY_TIMEOUT_MS;
306
- // --- poll until present AND fully written: existsSync fires before the write finishes ---
307
- let conn: ConnectionFile;
308
- while (true) {
309
- if (child.exitCode !== null) {
310
- throw new Error(
311
- `ipykernel exited before writing its connection file (code=${child.exitCode})` +
312
- (stderrTail ? `\nkernel stderr:\n${stderrTail}` : ""),
313
- );
314
- }
315
- if (Date.now() > deadline) {
316
- child.kill("SIGKILL");
317
- throw new Error("ipykernel did not write a valid connection file in time");
318
- }
319
- try {
320
- conn = readConnectionFile(connPath);
321
- break;
322
- } catch {
323
- await new Promise((resolve) => setTimeout(resolve, 25));
324
- }
325
- }
326
-
327
- const kc = new KernelClient(conn, opts);
151
+ const kc = new KernelClient(opts);
328
152
  kc.child = child;
329
- kc.connectionFilePath = connPath;
153
+ child.stdout?.on("data", (chunk) => kc.onData(chunk));
330
154
  child.on("exit", () => {
331
- // --- clear child/ready on death so isRunning reflects it and the engine never resumes a zombie ---
155
+ // --- bridge exit IS kernel death: settle the running cell and drop the zombie ---
332
156
  kc.settleActive(new Error("kernel process exited"));
333
- kc.child = undefined;
334
157
  kc.ready = false;
335
- kc.shell?.close();
336
- kc.control?.close();
337
- kc.iopub?.close();
338
- kc.shell = undefined;
339
- kc.control = undefined;
340
- kc.iopub = undefined;
158
+ kc.child = undefined;
159
+ kc.rejectBootWaiters(new Error("bridge exited before it was ready"));
341
160
  kc._onUnexpectedExit?.();
342
161
  });
343
162
 
163
+ // helpers resolve host-side (one canonical list, same skip rules as the prompt); sources preload in the bridge
164
+ const helpers = (
165
+ opts.env?.PI_HELPERS_DIR
166
+ ? readHelperSources([opts.env.PI_HELPERS_DIR])
167
+ : readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR))
168
+ ).map((h) => ({ name: h.name, source: h.source }));
169
+ kc.send({ op: "boot", helpers, snapshot: opts.snapshot });
170
+
344
171
  try {
345
- await kc.connectChannels(conn);
346
- await kc.probeReady();
347
- kc.helperBootReport = await kc.preload();
348
- kc.ready = true;
172
+ await kc.waitReady();
349
173
  } catch (error) {
350
174
  kc.kill();
351
- throw error;
175
+ throw error instanceof Error
176
+ ? new Error(`${error.message}${stderrTail ? `\nbridge stderr:\n${stderrTail}` : ""}`)
177
+ : error;
352
178
  }
179
+ kc.ready = true;
353
180
  return kc;
354
181
  }
355
182
 
356
- private async connectChannels(conn: ConnectionFile): Promise<void> {
357
- if (conn.transport !== "tcp") {
358
- throw new Error(`unsupported kernel transport "${conn.transport}" (only tcp) — set PI_KERNEL_TRANSPORT=tcp`);
183
+ private onData(chunk: Uint8Array): void {
184
+ this.inputBuffer += Buffer.from(chunk).toString("utf8");
185
+ let idx = this.inputBuffer.indexOf("\n");
186
+ while (idx >= 0) {
187
+ const line = this.inputBuffer.slice(0, idx).trim();
188
+ this.inputBuffer = this.inputBuffer.slice(idx + 1);
189
+ if (line) this.handleLine(line);
190
+ idx = this.inputBuffer.indexOf("\n");
359
191
  }
360
- const [shell, control, iopub] = await Promise.all([
361
- ZmtpSocket.connect({ host: conn.ip, port: conn.shell_port, socketType: "DEALER" }),
362
- ZmtpSocket.connect({ host: conn.ip, port: conn.control_port, socketType: "DEALER" }),
363
- ZmtpSocket.connect({ host: conn.ip, port: conn.iopub_port, socketType: "SUB" }),
364
- ]);
365
- this.shell = shell;
366
- this.control = control;
367
- this.iopub = iopub;
368
- shell.onMessage = (frames) => this.onShellMessage(frames);
369
- control.onMessage = (frames) => this.onControlMessage(frames);
370
- iopub.onMessage = (frames) => this.onIopubMessage(frames);
371
- iopub.subscribe(Buffer.from([])); // all traffic
372
192
  }
373
193
 
374
- private probeReady(): Promise<void> {
375
- const msgId = this.session.nextMsgId();
376
- this.shell?.send(this.session.buildFrames("kernel_info_request", {}, null, msgId));
377
- return this.waitForReply(msgId, KERNEL_READY_TIMEOUT_MS, "kernel_info_reply").then(() => {});
378
- }
379
-
380
- /** One cell per helper: a broken helper (syntax error or top-level raise) must cost itself alone, not abort the rest. */
381
- private async preload(): Promise<HelperLoadResult[]> {
382
- const report: HelperLoadResult[] = [];
383
- for (const h of this.helperSources) {
384
- const res = await this.executeCell(h.source, { maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS });
385
- if (res.status === "ok") {
386
- report.push({ name: h.name, ok: true });
387
- } else {
388
- const err = res.error;
389
- report.push({
390
- name: h.name,
391
- ok: false,
392
- error: err ? `${err.name}: ${err.message}` : "failed to load",
393
- });
394
- }
395
- }
396
- return report;
397
- }
398
-
399
- private onShellMessage(frames: Buffer[]): void {
400
- const msg = this.session.parseMessage(frames);
401
- if (!isTrustedMessage(msg)) return;
402
- const active = this.activeCell;
403
- if (msg.msg_type === "execute_reply" && active && msg.parent.msg_id === active.msgId) {
404
- // --- the shell reply races the iopub stream: record it, settle only after idle ---
405
- active.reply = msg;
406
- active.replySeen = true;
407
- this.maybeSettle(active);
194
+ /** Route-gate: a line that is not valid JSON is not the bridge. */
195
+ private handleLine(line: string): void {
196
+ let msg: BridgeMessage;
197
+ try {
198
+ msg = JSON.parse(line) as BridgeMessage;
199
+ } catch {
200
+ console.error("[pi-repl] unparseable bridge line:", line.slice(0, 200));
408
201
  return;
409
202
  }
410
- if (msg.msg_type === "kernel_info_reply" || msg.msg_type === "execute_reply") this.resolveReply(msg);
411
- }
412
-
413
- private onControlMessage(frames: Buffer[]): void {
414
- const msg = this.session.parseMessage(frames);
415
- if (!isTrustedMessage(msg)) return;
416
- // interrupt_reply / shutdown_reply — nothing awaits them; keep draining.
417
- this.resolveReply(msg);
418
- }
419
-
420
- private onIopubMessage(frames: Buffer[]): void {
421
- const msg = this.session.parseMessage(frames);
422
- if (!isTrustedMessage(msg)) return;
423
- const active = this.activeCell;
424
- if (!active || msg.parent.msg_id !== active.msgId) return;
425
- active.lastActivity = Date.now();
426
- const c = msg.content;
427
- switch (msg.msg_type) {
428
- case "stream": {
429
- const text = (c.text as string) ?? "";
430
- this.accumulate(active, c.name === "stderr" ? "stderr" : "stdout", text);
203
+ switch (msg.type) {
204
+ case "ready": {
205
+ const helpers = Array.isArray(msg.helpers) ? (msg.helpers as HelperLoadResult[]) : [];
206
+ this.helperBootReport = helpers;
207
+ this.readyWaiters.shift()?.resolve(msg);
431
208
  break;
432
209
  }
433
- case "execute_result": {
434
- const data = c.data as Record<string, unknown> | undefined;
435
- const plain = data?.["text/plain"];
436
- if (typeof plain === "string") active.result = plain;
437
- this.collectPayload(active, c);
210
+ case "stream": {
211
+ const active = this.activeCell;
212
+ if (active && msg.id === active.id) {
213
+ active.lastActivity = Date.now();
214
+ this.accumulate(active, msg.name === "stderr" ? "stderr" : "stdout", String(msg.text ?? ""));
215
+ }
438
216
  break;
439
217
  }
440
- case "display_data":
441
- this.collectPayload(active, c);
218
+ case "result": {
219
+ const active = this.activeCell;
220
+ if (active && msg.id === active.id) this.settleFromResult(active, msg);
442
221
  break;
443
- case "error": {
444
- const traceback = Array.isArray(c.traceback) ? (c.traceback as string[]) : [];
445
- active.error = {
446
- name: (c.ename as string) ?? "Error",
447
- message: (c.evalue as string) ?? traceback.join("\n"),
448
- stack: traceback,
449
- };
222
+ }
223
+ case "reply": {
224
+ const id = msg.id;
225
+ if (id !== undefined) {
226
+ const pending = this.pending.get(id);
227
+ if (pending) {
228
+ this.pending.delete(id);
229
+ if (pending.timer) clearTimeout(pending.timer);
230
+ pending.resolve(msg);
231
+ }
232
+ }
450
233
  break;
451
234
  }
452
- case "status":
453
- // --- status idle is published after every byte; the cell is complete only once we have it ---
454
- if (c.execution_state === "idle") {
455
- active.idleSeen = true;
456
- this.maybeSettle(active);
235
+ case "error": {
236
+ const error = new Error(String(msg.message ?? "bridge error"));
237
+ const id = msg.id;
238
+ if (id !== undefined && this.pending.has(id)) {
239
+ const pending = this.pending.get(id)!;
240
+ this.pending.delete(id);
241
+ if (pending.timer) clearTimeout(pending.timer);
242
+ pending.reject(error);
243
+ } else if (this.readyWaiters.length > 0) {
244
+ this.readyWaiters.shift()!.reject(error); // boot failure
245
+ } else {
246
+ console.error("[pi-repl] bridge error:", error.message);
457
247
  }
458
248
  break;
459
- }
460
- }
461
-
462
- private collectPayload(active: ActiveCell, content: Record<string, unknown>): void {
463
- for (const mime of [SNAPSHOT_MIME, RESTORE_MIME, NAMES_MIME]) {
464
- const payload = readPayload(content, mime);
465
- if (payload !== null) {
466
- active.payloads[mime] = payload;
467
- return;
468
249
  }
250
+ default:
251
+ console.error("[pi-repl] unknown bridge event:", JSON.stringify(msg).slice(0, 200));
469
252
  }
470
253
  }
471
254
 
472
- private accumulate(active: ActiveCell, name: "stdout" | "stderr", text: string): void {
473
- const arr = name === "stdout" ? active.stdout : active.stderr;
474
- const len = name === "stdout" ? active.outLen : active.errLen;
475
- const room = active.maxChars - len;
476
- const keep = Math.min(text.length, Math.max(0, room));
477
- if (keep > 0) {
478
- arr.push(text.slice(0, keep));
479
- if (name === "stdout") active.outLen += keep;
480
- else active.errLen += keep;
481
- }
482
- // --- one oversized stream frame (10 MB print) overflows the cap within this call ---
483
- if (text.length > keep) {
484
- if (name === "stdout") active.stdoutTruncated = true;
485
- else active.stderrTruncated = true;
486
- }
487
- // --- beyond the cap we drop text but keep draining ---
488
- active.onStream?.(text.slice(0, keep), name);
255
+ private send(msg: object): void {
256
+ this.child?.stdin?.write(JSON.stringify(msg) + "\n");
489
257
  }
490
258
 
491
- private waitForReply(msgId: string, timeoutMs: number, expectedType: string): Promise<ParsedMessage> {
492
- return new Promise<ParsedMessage>((resolve, reject) => {
493
- const timer = setTimeout(() => {
494
- this.pendingReplies.delete(msgId);
495
- reject(new Error(`kernel did not answer ${expectedType} in time`));
496
- }, timeoutMs);
497
- timer.unref?.();
498
- this.pendingReplies.set(msgId, { resolve, timer, expectedType });
259
+ /** Resolves on the bridge's ready event; rejects on a boot error or a bridge exit.
260
+ * A wedged (alive but silent) bridge is bounded by the lifecycle's boot race. */
261
+ private waitReady(): Promise<void> {
262
+ return new Promise<void>((resolve, reject) => {
263
+ this.readyWaiters.push({ resolve: () => resolve(), reject });
499
264
  });
500
265
  }
501
266
 
502
- private resolveReply(msg: ParsedMessage): void {
503
- const pending = this.pendingReplies.get(msg.parent.msg_id as string);
504
- if (!pending) return;
505
- // --- any reply echoes the parent id; only the awaited type settles the wait, the timer stays armed for it ---
506
- if (msg.msg_type !== pending.expectedType) return;
507
- this.pendingReplies.delete(msg.parent.msg_id as string);
508
- if (pending.timer) clearTimeout(pending.timer);
509
- pending.resolve(msg);
510
- }
511
-
512
- executeCell(code: string, opts: CellOptions = {}): Promise<CellResult> {
513
- return this.enqueue(() => this.executeCellNow(code, opts)).then(({ payloads: _payloads, ...rest }) => rest);
267
+ private rejectBootWaiters(error: Error): void {
268
+ const waiters = this.readyWaiters.splice(0);
269
+ for (const w of waiters) w.reject(error);
514
270
  }
515
271
 
516
272
  private enqueue<T>(fn: () => Promise<T>): Promise<T> {
@@ -519,18 +275,39 @@ export class KernelClient {
519
275
  return result;
520
276
  }
521
277
 
522
- private executeCellNow(code: string, opts: CellOptions): Promise<CellResult & { payloads: Record<string, string> }> {
523
- const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
524
- // --- one msg_id per request; the kernel echoes it as the reply's parent for routing ---
525
- const msgId = this.session.nextMsgId();
278
+ /** A request/reply op: snapshot, restore, listNames, shutdown. Serialized with cells. */
279
+ private request(msg: Record<string, unknown>, timeoutMs?: number): Promise<BridgeMessage> {
280
+ return this.enqueue(() => {
281
+ const id = `r${this.nextId++}`;
282
+ return new Promise<BridgeMessage>((resolve, reject) => {
283
+ const timer = timeoutMs
284
+ ? setTimeout(() => {
285
+ this.pending.delete(id);
286
+ reject(new Error(`bridge did not answer ${String(msg.op)} in time`));
287
+ }, timeoutMs)
288
+ : undefined;
289
+ timer?.unref?.();
290
+ this.pending.set(id, { resolve, reject, timer });
291
+ this.send({ ...msg, id });
292
+ });
293
+ });
294
+ }
295
+
296
+ executeCell(code: string, opts: CellOptions): Promise<CellResult> {
297
+ return this.enqueue(() => this.executeCellNow(code, opts));
298
+ }
299
+
300
+ private executeCellNow(code: string, opts: CellOptions): Promise<CellResult> {
301
+ const maxChars = opts.maxOutputChars;
302
+ // one op id per request; the bridge echoes it on every stream and the result event
303
+ const id = `e${this.nextId++}`;
526
304
  const active: ActiveCell = {
527
- msgId,
305
+ id,
528
306
  stdout: [],
529
307
  stderr: [],
530
308
  outLen: 0,
531
309
  errLen: 0,
532
310
  maxChars,
533
- payloads: {},
534
311
  lastActivity: Date.now(),
535
312
  timedOut: false,
536
313
  stdoutTruncated: false,
@@ -539,8 +316,6 @@ export class KernelClient {
539
316
  onStream: opts.onStream,
540
317
  status: "ok",
541
318
  settled: false,
542
- replySeen: false,
543
- idleSeen: false,
544
319
  resolve: () => {},
545
320
  reject: () => {},
546
321
  };
@@ -548,11 +323,11 @@ export class KernelClient {
548
323
  const onAbort = () => this.interrupt();
549
324
  opts.signal?.addEventListener("abort", onAbort, { once: true });
550
325
 
551
- this.shell?.send(this.session.buildFrames("execute_request", executeRequest(code, false), null, msgId));
326
+ this.send({ op: "exec", id, code });
552
327
  this.startWatchdog(active);
553
328
 
554
- return new Promise<CellResult & { payloads: Record<string, string> }>((resolve, reject) => {
555
- active.resolve = (result) => resolve(result);
329
+ return new Promise<CellResult>((resolve, reject) => {
330
+ active.resolve = resolve;
556
331
  active.reject = reject;
557
332
  if (opts.signal?.aborted) this.interrupt();
558
333
  }).finally(() => {
@@ -569,30 +344,29 @@ export class KernelClient {
569
344
  active.reject(error);
570
345
  }
571
346
 
572
- private maybeSettle(active: ActiveCell): void {
573
- // --- settle only once the shell reply AND the idle iopub stream arrive, else output is dropped ---
574
- if (active.settled || !active.replySeen || !active.idleSeen) return;
575
- this.settleFromReply(active, active.reply!);
576
- }
577
-
578
- private settleFromReply(active: ActiveCell, msg: ParsedMessage): void {
347
+ private settleFromResult(active: ActiveCell, msg: BridgeMessage): void {
348
+ // the bridge settles only once the shell reply AND the iopub idle arrived
579
349
  if (active.settled) return;
580
350
  active.settled = true;
581
- const content = msg.content;
582
- const replyStatus = (content.status as string) ?? "ok";
583
- if (replyStatus === "error" && !active.error) {
351
+ const content = msg as {
352
+ status?: string;
353
+ result?: string;
354
+ error?: { name?: string; message?: string; stack?: string[] };
355
+ };
356
+ if (content.result !== undefined) active.result = content.result;
357
+ if (content.error) {
584
358
  active.error = {
585
- name: (content.ename as string) ?? "Error",
586
- message: (content.evalue as string) ?? "error",
587
- stack: [],
359
+ name: content.error.name ?? "Error",
360
+ message: content.error.message ?? "",
361
+ stack: content.error.stack ?? [],
588
362
  };
589
363
  }
590
- let status: CellResult["status"] = replyStatus === "aborted" ? "aborted" : active.error ? "error" : "ok";
364
+ let status: CellResult["status"] = content.status === "aborted" ? "aborted" : active.error ? "error" : "ok";
591
365
  if (active.signal?.aborted) {
592
- // --- the caller withdrew; report aborted even if the cell raised ---
366
+ // the caller withdrew; report aborted even if the cell raised
593
367
  status = "aborted";
594
368
  } else if (active.timedOut) {
595
- // --- silence watchdog tripped: the cell was still running, not done ---
369
+ // silence watchdog tripped: the cell was still running, not done
596
370
  status = "error";
597
371
  active.error = {
598
372
  name: "Timeout",
@@ -608,10 +382,28 @@ export class KernelClient {
608
382
  error: active.error,
609
383
  status,
610
384
  truncated: { stdout: active.stdoutTruncated, stderr: active.stderrTruncated },
611
- payloads: active.payloads,
612
385
  });
613
386
  }
614
387
 
388
+ private accumulate(active: ActiveCell, name: "stdout" | "stderr", text: string): void {
389
+ const arr = name === "stdout" ? active.stdout : active.stderr;
390
+ const len = name === "stdout" ? active.outLen : active.errLen;
391
+ const room = active.maxChars - len;
392
+ const keep = Math.min(text.length, Math.max(0, room));
393
+ if (keep > 0) {
394
+ arr.push(text.slice(0, keep));
395
+ if (name === "stdout") active.outLen += keep;
396
+ else active.errLen += keep;
397
+ }
398
+ // one oversized stream frame (10 MB print) overflows the cap within this call
399
+ if (text.length > keep) {
400
+ if (name === "stdout") active.stdoutTruncated = true;
401
+ else active.stderrTruncated = true;
402
+ }
403
+ // beyond the cap we drop text but keep draining
404
+ active.onStream?.(text.slice(0, keep), name);
405
+ }
406
+
615
407
  private startWatchdog(active: ActiveCell): void {
616
408
  this.stopWatchdog();
617
409
  if (!this.timeoutMs) return;
@@ -621,7 +413,7 @@ export class KernelClient {
621
413
  if (quiet >= this.timeoutMs && !active.settled) {
622
414
  active.timedOut = true;
623
415
  this.interrupt();
624
- // --- a cell that swallows the interrupt never replies; escalate to a kill so the queue frees ---
416
+ // a cell that swallows the interrupt never replies; escalate to a kill so the queue frees
625
417
  this.silenceKillTimer ??= setTimeout(() => {
626
418
  if (!active.settled) this.kill();
627
419
  }, SILENCE_KILL_GRACE_MS);
@@ -644,107 +436,91 @@ export class KernelClient {
644
436
  }
645
437
  }
646
438
 
647
- /** Genuine KeyboardInterrupt via control-channel interrupt_request; the kernel survives. */
439
+ /** Genuine KeyboardInterrupt via the bridge's control channel; the kernel survives. */
648
440
  interrupt(): void {
649
- const active = this.activeCell;
650
- if (!active || active.settled) return;
651
- this.control?.send(this.session.buildFrames("interrupt_request", {}, null));
441
+ this.send({ op: "interrupt" });
652
442
  }
653
443
 
654
- snapshot(maxBytes: number): Promise<SnapshotReply> {
655
- return this.enqueue(async () => {
656
- const res = await this.executeCellNow(
657
- snapshotCode(
658
- this.helperSources.map((h) => h.name),
659
- maxBytes,
660
- ),
661
- {
662
- maxOutputChars: 8_000_000,
663
- },
664
- );
665
- const payload = res.payloads[SNAPSHOT_MIME];
666
- if (payload === undefined) return { entries: [], failed: [], complete: false };
667
- try {
668
- const obj = JSON.parse(payload) as {
669
- entries?: SnapshotEntry[];
670
- failed?: { name: string; reason: string }[];
671
- };
672
- return { entries: obj.entries ?? [], failed: obj.failed ?? [], complete: true };
673
- } catch {
674
- return { entries: [], failed: [], complete: false };
444
+ snapshot(path: string, maxBytes: number): Promise<SnapshotReply> {
445
+ return this.request({ op: "snapshot", path, max_bytes: maxBytes }).then((msg) => {
446
+ const m = msg as {
447
+ saved?: string[];
448
+ entries?: { name: string; kind: "value" | "def"; payload: string }[];
449
+ failed?: { name: string; reason: string }[];
450
+ complete?: boolean;
451
+ bytes?: number;
452
+ error?: string;
453
+ };
454
+ if (m.error !== undefined || m.complete === undefined) {
455
+ throw new Error(String(m.error ?? "snapshot failed"));
675
456
  }
457
+ return { saved: m.saved, entries: m.entries, failed: m.failed ?? [], complete: m.complete, bytes: m.bytes };
676
458
  });
677
459
  }
678
460
 
679
- restore(
680
- entries: SnapshotEntry[],
681
- compressedValues = false,
682
- ): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
683
- if (entries.length === 0) return Promise.resolve({ restored: [], failed: [] });
684
- return this.enqueue(async () => {
685
- const res = await this.executeCellNow(restoreCode(entries, compressedValues), { maxOutputChars: 8_000_000 });
686
- const payload = res.payloads[RESTORE_MIME];
687
- if (payload === undefined) return { restored: [], failed: [] };
688
- try {
689
- const obj = JSON.parse(payload) as { restored?: string[]; failed?: { name: string; reason: string }[] };
690
- return { restored: obj.restored ?? [], failed: obj.failed ?? [] };
691
- } catch {
692
- return { restored: [], failed: [] };
693
- }
461
+ restore(path: string, defer = false): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
462
+ if (defer) {
463
+ // background revive: bypass the op queue — a queued restore would hold every user
464
+ // cell behind its completion. The bridge runs it at its first quiet gap instead.
465
+ const id = `r${this.nextId++}`;
466
+ return new Promise<BridgeMessage>((resolve, reject) => {
467
+ this.pending.set(id, { resolve, reject });
468
+ this.send({ op: "restore", path, defer: true, id });
469
+ }).then((msg) => {
470
+ const m = msg as { restored?: string[]; failed?: { name: string; reason: string }[]; error?: string };
471
+ if (m.error !== undefined) throw new Error(m.error);
472
+ return { restored: m.restored ?? [], failed: m.failed ?? [] };
473
+ });
474
+ }
475
+ return this.request({ op: "restore", path }).then((msg) => {
476
+ const m = msg as { restored?: string[]; failed?: { name: string; reason: string }[]; error?: string };
477
+ if (m.error !== undefined) throw new Error(m.error);
478
+ return { restored: m.restored ?? [], failed: m.failed ?? [] };
694
479
  });
695
480
  }
696
481
 
697
482
  listNames(): Promise<string[]> {
698
- return this.enqueue(async () => {
699
- const res = await this.executeCellNow(namesCode(this.helperSources.map((h) => h.name)), {
700
- maxOutputChars: 8_000_000,
701
- });
702
- const payload = res.payloads[NAMES_MIME];
703
- if (payload === undefined) return [];
704
- try {
705
- const arr = JSON.parse(payload) as unknown;
706
- return Array.isArray(arr) ? (arr as string[]) : [];
707
- } catch {
708
- return [];
709
- }
483
+ return this.request({ op: "listNames" }).then((msg) => {
484
+ const names = (msg as { names?: unknown }).names;
485
+ return Array.isArray(names) ? (names as string[]) : [];
710
486
  });
711
487
  }
712
488
 
713
- /** Graceful stop: shutdown_request on control, then SIGKILL as backstop. */
489
+ /** Graceful stop: shutdown op, then SIGKILL the process group as backstop. */
714
490
  async shutdown(): Promise<void> {
715
- const child = this.child;
716
- if (this.ready && this.control && child && child.exitCode === null) {
717
- this.control.send(this.session.buildFrames("shutdown_request", { restart: false }, null));
718
- await Promise.race([this.childExit(), new Promise((resolve) => setTimeout(resolve, 2000).unref?.())]).catch(
719
- () => {},
720
- );
491
+ if (this.ready && this.child) {
492
+ await this.request({ op: "shutdown" }, 2000).catch(() => {});
721
493
  }
722
494
  this.kill();
723
495
  }
724
496
 
725
- private childExit(): Promise<void> {
726
- const child = this.child;
727
- if (!child) return Promise.resolve();
728
- return child.exitCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
729
- }
730
-
731
497
  kill(): void {
732
498
  this.stopWatchdog();
733
499
  this.settleActive(new Error("kernel killed"));
734
- this.shell?.close();
735
- this.control?.close();
736
- this.iopub?.close();
737
- this.child?.kill("SIGKILL");
738
- this.child = undefined;
739
- if (this.connectionFilePath) {
500
+ const pid = this.child?.pid;
501
+ if (pid !== undefined) {
502
+ // group kill: the bridge's kernel is in the same process group (detached spawn)
740
503
  try {
741
- rmSync(this.connectionFilePath, { force: true });
742
- } catch {}
743
- this.connectionFilePath = undefined;
504
+ process.kill(-pid, "SIGKILL");
505
+ } catch {
506
+ try {
507
+ process.kill(pid, "SIGKILL");
508
+ } catch {}
509
+ }
744
510
  }
511
+ this.child = undefined;
512
+ }
513
+
514
+ /** Engine hook: an unexpected bridge exit (not a deliberate kill) should drop the instance. */
515
+ setOnUnexpectedExit(fn: () => void): void {
516
+ this._onUnexpectedExit = fn;
517
+ }
518
+
519
+ get helperReport(): readonly HelperLoadResult[] {
520
+ return this.helperBootReport;
745
521
  }
746
522
 
747
523
  get isRunning(): boolean {
748
- return this.ready && this.child !== undefined;
524
+ return this.ready && this.child !== undefined && this.child.exitCode === null;
749
525
  }
750
526
  }