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,9 +1,9 @@
1
1
  // --- EngineManager: venv resolution, spawn, queue, snapshots, abort grace, teardown; the wire lives in kernel.ts ---
2
2
 
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
- import { type HelperLoadResult, KernelClient, type SnapshotEntry } from "./kernel.js";
6
+ import { type HelperLoadResult, KernelClient } from "./kernel.js";
7
7
 
8
8
  export type { HelperLoadResult } from "./kernel.js";
9
9
 
@@ -18,19 +18,15 @@ function resolvePythonPath(_cwd: string | undefined): string {
18
18
  return process.env.PYTHON ?? "python3";
19
19
  }
20
20
 
21
- const DEFAULT_MAX_OUTPUT_CHARS = 46080;
21
+ export const DEFAULT_MAX_OUTPUT_CHARS = 46080;
22
22
  /** Per-line cap: one giant line must not own the channel budget while long JSON/reprs/errors still pass whole. */
23
23
  export const MAX_OUTPUT_LINE_CHARS = 4096;
24
24
  const ABORT_GRACE_MS = 20_000;
25
- const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500;
26
- const DEFAULT_SNAPSHOT_PERIOD_MS = 120_000;
27
- /** Guard: a periodic refresh stands down for already-heavy namespaces — they re-arm on name churn anyway. */
28
- export const FORCED_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024;
25
+ /** Shared deadline: bounds a wedged boot (lifecycle) and a wedged background restore alike. */
26
+ export const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
29
27
  /** Snapshot size cap, also per-entry; oversized bindings are reported as skipped names. */
30
28
  const DEFAULT_SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024;
31
- const RESTORE_QUIET_MS = 250;
32
- /** Restore-cell deadline: a poisoned pickle would wedge the kernel's single queue forever — kill and mark skipped. */
33
- const DEFAULT_RESTORE_DEADLINE_MS = 90_000;
29
+ const DEFAULT_SNAPSHOT_PERIOD_MS = 120_000;
34
30
 
35
31
  interface EngineExecuteError {
36
32
  name: string;
@@ -72,7 +68,6 @@ export interface EngineOptions {
72
68
  env?: Record<string, string>;
73
69
  snapshot?: {
74
70
  path: string;
75
- debounceMs?: number;
76
71
  /** Total base64 payload cap; also the per-entry cap. Oversized entries are skipped with a reason. Default 128 MiB. */
77
72
  maxBytes?: number;
78
73
  /** Force a refresh when the last persisted snapshot is older than this, even if no name changed. 0 disables. Default 2 min. */
@@ -181,16 +176,10 @@ export class EngineManager {
181
176
  private state: "idle" | "starting" | "running" | "shutdown" = "idle";
182
177
  private startPromise?: Promise<void>;
183
178
  private executionQueue: Promise<unknown> = Promise.resolve();
184
- private snapshotTimer?: ReturnType<typeof setTimeout>;
185
- /** In-flight user cells; the debounced snapshot never cuts in front of one. */
186
- private inFlightCells = 0;
187
- /** Last-seen namespace names; the snapshot is gated on this set changing. */
188
- private lastNamespaceNames?: string[];
189
179
  private pythonPath?: string;
190
180
  private restoredKernel?: KernelClient;
191
181
  /** A wedged revive marks the engine: later kernels boot without restoring. */
192
182
  private restoreSkipped: boolean;
193
- private restoreTimer?: ReturnType<typeof setTimeout>;
194
183
  private restoreResolve?: (result: RestoreResult | null) => void;
195
184
  private restorePromise?: Promise<RestoreResult | null>;
196
185
  private restoreSettledResult?: RestoreResult | null;
@@ -198,10 +187,6 @@ export class EngineManager {
198
187
  /** Whether the current boot's report has been handed out (once per boot). */
199
188
  private helperReportTaken = true;
200
189
  private readonly forkInherited: boolean;
201
- /** When the last snapshot was persisted; 0 = never. Drives the periodic refresh. */
202
- private lastPersistedAt = 0;
203
- /** Payload bytes of the last persisted snapshot; the periodic refresh stands down above FORCED_SNAPSHOT_MAX_BYTES. */
204
- private lastSnapshotBytes = 0;
205
190
 
206
191
  constructor(options: EngineOptions = {}) {
207
192
  this.options = options;
@@ -253,10 +238,18 @@ export class EngineManager {
253
238
  this.pythonPath = resolvePythonPath(this.options.cwd);
254
239
  const timeoutMs = Number(process.env.PI_REPL_TIMEOUT_MS ?? this.options.env?.PI_REPL_TIMEOUT_MS ?? 0) || 0;
255
240
  try {
241
+ const snap = this.options.snapshot;
256
242
  this.kernel = await KernelClient.start(this.pythonPath, {
257
243
  cwd: this.options.cwd,
258
244
  env: this.options.env,
259
245
  timeoutMs,
246
+ snapshot: snap
247
+ ? {
248
+ path: snap.path,
249
+ max_bytes: snap.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES,
250
+ period_ms: snap.periodMs ?? DEFAULT_SNAPSHOT_PERIOD_MS,
251
+ }
252
+ : undefined,
260
253
  });
261
254
  // --- a fresh boot's helper verdicts are announced once, on the first cell after it ---
262
255
  this.helperReport = this.kernel.helperReport;
@@ -267,7 +260,6 @@ export class EngineManager {
267
260
  if (this.kernel !== current) return;
268
261
  this.kernel = undefined;
269
262
  this.startPromise = undefined;
270
- this.lastNamespaceNames = undefined;
271
263
  this.helperReport = null;
272
264
  });
273
265
  } catch (error) {
@@ -282,13 +274,12 @@ export class EngineManager {
282
274
  throw new Error("Engine has been shut down");
283
275
  }
284
276
  this.state = "running";
285
- // --- recovery runs in the first quiet gap — never ahead of a user cell, never on the first call's critical path ---
286
- this.maybeScheduleRestore();
277
+ // --- recovery runs in the bridge's first quiet gap — never ahead of a user cell ---
278
+ this.restoreInBackground();
287
279
  }
288
280
 
289
281
  /** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
290
282
  killSync(): void {
291
- this.clearSnapshotTimer();
292
283
  this.state = "shutdown";
293
284
  liveEngines.delete(this);
294
285
  this.kernel?.kill();
@@ -361,15 +352,12 @@ export class EngineManager {
361
352
  };
362
353
  opts.signal?.addEventListener("abort", onAbort, { once: true });
363
354
 
364
- this.inFlightCells++;
365
355
  try {
366
356
  const r = await this.kernel!.executeCell(code, {
367
357
  signal: opts.signal,
368
358
  onStream: opts.onStream,
369
359
  maxOutputChars: maxChars,
370
360
  });
371
- // --- names gate runs off the critical path so the next cell enqueues before it ---
372
- if (r.status === "ok") setImmediate(() => void this.scheduleSnapshotIfChanged());
373
361
  const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
374
362
  // Channel cap (truncateWithMarker), then per-line cap; both append a marker so truncation is explicit.
375
363
  const finalize = (text: string, channelTruncated: boolean): string => {
@@ -395,7 +383,6 @@ export class EngineManager {
395
383
  } finally {
396
384
  opts.signal?.removeEventListener("abort", onAbort);
397
385
  if (graceTimer) clearTimeout(graceTimer);
398
- this.inFlightCells--;
399
386
  }
400
387
  } finally {
401
388
  release();
@@ -406,29 +393,17 @@ export class EngineManager {
406
393
  const config = this.options.snapshot;
407
394
  if (!config || this.state !== "running" || !this.kernel) return null;
408
395
  try {
409
- const reply = await this.kernel.snapshot(config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
396
+ const reply = await this.kernel.snapshot(config.path, config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
410
397
  // --- an incomplete snapshot must not overwrite the last good file ---
411
398
  if (reply.complete === false) return null;
412
- mkdirSync(dirname(config.path), { recursive: true });
413
- // --- atomic write: temp file + rename, so a crash can't corrupt the last good snapshot ---
414
- const tmp = `${config.path}.tmp`;
415
- writeFileSync(tmp, JSON.stringify({ version: 3, entries: reply.entries, failed: reply.failed }));
416
- renameSync(tmp, config.path);
417
- this.lastPersistedAt = Date.now();
418
- this.lastSnapshotBytes = reply.entries.reduce((n, e) => n + e.payload.length, 0);
419
- await this.advanceSnapshotGate();
420
- return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
399
+ // --- the bridge wrote the file atomically; names and counts only cross the pipe ---
400
+ const saved = reply.saved ?? reply.entries?.map((e) => e.name) ?? [];
401
+ return { path: config.path, saved, failed: reply.failed };
421
402
  } catch {
422
403
  return null;
423
404
  }
424
405
  }
425
406
 
426
- /** On success, advance the name-diff gate to today's names — a failed write never blocks the retry. */
427
- private async advanceSnapshotGate(): Promise<void> {
428
- const names = await this.listNamespaceNames();
429
- if (names !== null && names.length > 0) this.lastNamespaceNames = [...names].sort();
430
- }
431
-
432
407
  /** Restore outcome (never rejects); the background quiet-gap job — this promise is the lifecycle's only announce hook. */
433
408
  restoreResult(): Promise<RestoreResult | null> {
434
409
  if (this.restoreSettledResult !== undefined) return Promise.resolve(this.restoreSettledResult);
@@ -452,36 +427,46 @@ export class EngineManager {
452
427
  this.restoreResolve = undefined;
453
428
  }
454
429
 
455
- /** Background revive: fires in the first quiet gap (never ahead of a user cell); mid-session rebuilds force it synchronously. */
456
- private maybeScheduleRestore(): void {
430
+ /** Background revive: the bridge runs it at its first quiet gap (never ahead of a user cell);
431
+ * mid-session rebuilds force it synchronously instead. */
432
+ private restoreInBackground(): void {
457
433
  const config = this.options.snapshot;
458
- if (!config) return;
459
- if (this.restoreSkipped) {
434
+ const kernel = this.kernel;
435
+ if (!config || !kernel || this.restoreSkipped) {
460
436
  this.settleRestore(null);
461
437
  return;
462
438
  }
463
- if (this.kernel && this.kernel === this.restoredKernel) return; // this kernel already revived
439
+ if (kernel === this.restoredKernel) return; // this kernel already revived
464
440
  if (!existsSync(config.path)) {
465
441
  this.settleRestore(null);
466
442
  return;
467
443
  }
468
- if (this.restoreTimer) return; // already armed
469
- const arm = () => {
470
- this.restoreTimer = setTimeout(() => {
471
- this.restoreTimer = undefined;
472
- // --- quiet-gap rule: a pickling restore must not queue ahead of the user's next cell ---
473
- if (this.inFlightCells > 0 || !this.kernel?.isRunning) {
474
- arm();
475
- return;
476
- }
477
- void this.runRestore();
478
- }, RESTORE_QUIET_MS);
479
- this.restoreTimer.unref?.();
480
- };
481
- arm();
444
+ this.restoredKernel = kernel;
445
+ // a poisoned pickle would wedge the bridge's single loop forever — the boot deadline
446
+ // kills and marks the revive skipped instead
447
+ const deadlineMs =
448
+ Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? this.options.env?.PI_REPL_BOOT_TIMEOUT_MS ?? 0) ||
449
+ DEFAULT_BOOT_TIMEOUT_MS;
450
+ const reaper = setTimeout(() => {
451
+ this.restoreSkipped = true;
452
+ this.settleRestore(null);
453
+ this.kernel?.kill();
454
+ }, deadlineMs);
455
+ reaper.unref?.();
456
+ kernel
457
+ .restore(config.path, true)
458
+ .then((reply) => {
459
+ clearTimeout(reaper);
460
+ const result: RestoreResult = { path: config.path, restored: reply.restored, failed: reply.failed };
461
+ this.settleRestore(result);
462
+ })
463
+ .catch(() => {
464
+ clearTimeout(reaper);
465
+ this.settleRestore(null);
466
+ });
482
467
  }
483
468
 
484
- /** Restore-cell watchdog: an unpickling that never returns would wedge the single queue forever — kill, skip, and rebuild honestly. */
469
+ /** Restore-cell deadline: a poisoned pickle would wedge the bridge's single loop forever — kill, skip, and rebuild honestly. */
485
470
  private async restoreWithReap(): Promise<RestoreResult | null> {
486
471
  const config = this.options.snapshot;
487
472
  if (!config || !this.kernel || this.restoreSkipped) {
@@ -491,7 +476,7 @@ export class EngineManager {
491
476
  if (this.kernel === this.restoredKernel) return this.restoreResult();
492
477
  const deadlineMs =
493
478
  Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? this.options.env?.PI_REPL_BOOT_TIMEOUT_MS ?? 0) ||
494
- DEFAULT_RESTORE_DEADLINE_MS;
479
+ DEFAULT_BOOT_TIMEOUT_MS;
495
480
  const reaper = setTimeout(() => {
496
481
  this.restoreSkipped = true;
497
482
  this.settleRestore(null);
@@ -505,10 +490,6 @@ export class EngineManager {
505
490
  }
506
491
  }
507
492
 
508
- private async runRestore(): Promise<void> {
509
- await this.restoreWithReap();
510
- }
511
-
512
493
  /** Restore, idempotent per kernel: a second call shares the in-flight outcome. */
513
494
  async restoreState(skip = false): Promise<RestoreResult | null> {
514
495
  // --- start unconditionally: direct callers may not have started, and a wedged boot is only visible mid-attempt ---
@@ -526,33 +507,16 @@ export class EngineManager {
526
507
  if (kernel === this.restoredKernel) {
527
508
  return this.restoreResult();
528
509
  }
529
- // claim the kernel now so the quiet-gap scheduler cannot start a second restore cell
510
+ // claim the kernel now so a background revive cannot start a second restore cell
530
511
  this.restoredKernel = kernel;
531
512
  if (!existsSync(config.path)) {
532
513
  this.settleRestore(null);
533
514
  return null;
534
515
  }
535
516
  try {
536
- const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
537
- version?: number;
538
- entries?: SnapshotEntry[];
539
- vars?: Record<string, string>;
540
- failed?: { name: string; reason: string }[];
541
- };
542
- // --- v1 files (pre-source-capture) restore via plain pickles; v3 value entries are zlib-compressed ---
543
- const entries: SnapshotEntry[] =
544
- payload.version !== undefined && payload.version >= 2
545
- ? (payload.entries ?? [])
546
- : Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
547
- const reply = await kernel.restore(entries, payload.version === 3);
548
- // --- merge save-time skips (oversized bindings) into the result so the resume notice names every loss ---
549
- const failed = [...(payload.failed ?? [])];
550
- const seen = new Set(failed.map((f) => f.name));
551
- for (const f of reply.failed) {
552
- if (!seen.has(f.name)) failed.push(f);
553
- seen.add(f.name);
554
- }
555
- const result: RestoreResult = { path: config.path, restored: reply.restored, failed };
517
+ // v1/v2/v3 dispatch, un-pickling, and the save-time failure merge all live in the bridge
518
+ const reply = await kernel.restore(config.path);
519
+ const result: RestoreResult = { path: config.path, restored: reply.restored, failed: reply.failed };
556
520
  this.settleRestore(result);
557
521
  return result;
558
522
  } catch {
@@ -574,49 +538,4 @@ export class EngineManager {
574
538
  return null;
575
539
  }
576
540
  }
577
-
578
- /** Snapshot on name change, or when the last persisted snapshot went stale (periodMs) — same-name mutations would otherwise never re-arm; a failed write leaves the gate in place. */
579
- private async scheduleSnapshotIfChanged(): Promise<void> {
580
- const config = this.options.snapshot;
581
- if (!config) return;
582
- const names = await this.listNamespaceNames();
583
- if (names === null || names.length === 0) return;
584
- const key = [...names].sort().join(",");
585
- const prev = this.lastNamespaceNames ? [...this.lastNamespaceNames].sort().join(",") : undefined;
586
- const changed = prev === undefined || prev !== key;
587
- const periodMs = config.periodMs ?? DEFAULT_SNAPSHOT_PERIOD_MS;
588
- const stale =
589
- periodMs > 0 &&
590
- this.lastPersistedAt > 0 &&
591
- Date.now() - this.lastPersistedAt >= periodMs &&
592
- this.lastSnapshotBytes <= FORCED_SNAPSHOT_MAX_BYTES;
593
- if (!changed && !stale) return;
594
- this.scheduleSnapshot();
595
- }
596
-
597
- private scheduleSnapshot(): void {
598
- const config = this.options.snapshot;
599
- if (!config) return;
600
- this.clearSnapshotTimer();
601
- const quiet = config.debounceMs ?? DEFAULT_SNAPSHOT_DEBOUNCE_MS;
602
- const fire = () => {
603
- this.snapshotTimer = undefined;
604
- if (this.inFlightCells > 0) {
605
- // --- pickling must not queue ahead of the user's next request; re-arm the quiet window until the kernel is idle ---
606
- this.snapshotTimer = setTimeout(fire, quiet);
607
- this.snapshotTimer.unref?.();
608
- return;
609
- }
610
- void this.snapshotState();
611
- };
612
- this.snapshotTimer = setTimeout(fire, quiet);
613
- this.snapshotTimer.unref?.();
614
- }
615
-
616
- private clearSnapshotTimer(): void {
617
- if (this.snapshotTimer) {
618
- clearTimeout(this.snapshotTimer);
619
- this.snapshotTimer = undefined;
620
- }
621
- }
622
541
  }