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