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.
@@ -1,31 +1,22 @@
1
- // --- EngineManager: the host half; one python3 guest over a private fd3 line-JSON pipe ---
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
- decodeMessage,
12
- encodeMessage,
13
- type GuestToHostMessage,
14
- type HostToGuestMessage,
15
- NONCE_ENV,
16
- PROTOCOL_FD,
17
- } from "./protocol.js";
18
-
19
- const GUEST_PATH = fileURLToPath(new URL("./guest.py", import.meta.url));
20
-
21
- // --- venv created by the package postinstall ---
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
- // --- prefer a venv with ipykernel; else PYTHON or python3 ---
17
+ /** Prefer a venv with ipykernel; else $PYTHON or python3. */
27
18
  function resolvePythonPath(cwd: string | undefined): string {
28
- const repoVenv = join(dirname(GUEST_PATH), "..", "..", ".venv", "bin", "python3");
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 cooperatively; namespace is preserved. */
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 readonly pythonPath: string;
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
- //--- lifecycle ---
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
- const pythonPath = this.pythonPath;
213
- const child = spawn(pythonPath, [GUEST_PATH], {
214
- cwd: this.options.cwd,
215
- env: {
216
- ...process.env,
217
- ...(this.options.env ?? {}),
218
- [NONCE_ENV]: this.nonce,
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 ((this.state as string) === "shutdown") throw new Error("Engine has been shut down");
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
- private transitionToShutdown(reason: string): void {
296
- this.state = "shutdown";
166
+ /** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
167
+ killSync(): void {
297
168
  this.clearSnapshotTimer();
298
- const active = this.activeExecution;
299
- if (active && !active.settled) {
300
- this.activeExecution = undefined;
301
- active.settled = true;
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
- /** Synchronous teardown, safe from process.on("exit"). */
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.kill();
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.state === "shutdown") {
201
+ if (this.isShutdown()) {
471
202
  throw new Error("Engine has been shut down");
472
203
  }
473
204
  await this.start();
474
- if ((this.state as string) === "shutdown") {
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
- active.abortRequested = true;
529
- active.hostAbort.abort();
530
- this.sendToGuest({ type: "abort", cellId });
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.activeExecution === active && !active.settled) {
534
- active.status = "aborted";
535
- this.settleActiveExecution(active);
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
- const originalResolve = active.resolve;
543
- active.resolve = (result) => {
544
- opts.signal?.removeEventListener("abort", onAbort);
545
- if (graceTimer) clearTimeout(graceTimer);
546
- originalResolve(result);
547
- };
548
- const originalReject = active.reject;
549
- active.reject = (error) => {
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
- originalReject(error);
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.request({ type: "snapshot", id: randomUUID() }, SNAPSHOT_REQUEST_TIMEOUT_MS);
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.request({ type: "restore", id: randomUUID(), vars }, SNAPSHOT_REQUEST_TIMEOUT_MS);
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
- const reply = await this.request({ type: "list_names", id: randomUUID() }, PING_TIMEOUT_MS);
626
- return reply.type === "names_result" ? reply.names : null;
292
+ return await this.kernel.listNames();
627
293
  } catch {
628
294
  return null;
629
295
  }