pi-repl-py 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
6
6
  "keywords": [
@@ -162,6 +162,15 @@ export class EngineManager {
162
162
  env: this.options.env,
163
163
  timeoutMs,
164
164
  });
165
+ // --- an unexpected kernel death must not survive the next execute: drop the dying
166
+ // --- instance and clear the boot cache so start() rebuilds it on the next cell. ---
167
+ const current = this.kernel;
168
+ current.setOnUnexpectedExit(() => {
169
+ if (this.kernel !== current) return;
170
+ this.kernel = undefined;
171
+ this.startPromise = undefined;
172
+ this.lastNamespaceNames = undefined;
173
+ });
165
174
  } catch (error) {
166
175
  if (this.state === "starting") this.state = "idle";
167
176
  liveEngines.delete(this);
@@ -215,6 +224,13 @@ export class EngineManager {
215
224
  throw new Error("Engine has been shut down");
216
225
  }
217
226
  await this.start();
227
+ // --- the kernel may have died after the boot promise resolved but before the async
228
+ // --- exit event surfaced it; drop the zombie and rebuild so the next cell runs. ---
229
+ if (this.kernel && !this.kernel.isRunning) {
230
+ this.kernel = undefined;
231
+ this.startPromise = undefined;
232
+ await this.start();
233
+ }
218
234
  if (this.isShutdown()) {
219
235
  throw new Error("Engine has been shut down");
220
236
  }
@@ -19,6 +19,7 @@ import {
19
19
  import { ZmtpSocket } from "./zmtp.js";
20
20
 
21
21
  const KERNEL_READY_TIMEOUT_MS = 30_000;
22
+ const SILENCE_KILL_GRACE_MS = 2000;
22
23
  const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000;
23
24
 
24
25
  export interface KernelOptions {
@@ -169,8 +170,13 @@ export class KernelClient {
169
170
  private ready = false;
170
171
  /** Serializes all kernel ops: one execute at a time, snapshots between cells. */
171
172
  private queue: Promise<unknown> = Promise.resolve();
172
- private onUnexpectedExit?: () => void;
173
+ private _onUnexpectedExit?: () => void;
174
+ /** Engine hook: an unexpected kernel death (not a deliberate kill) should drop the instance. */
175
+ setOnUnexpectedExit(fn: () => void): void {
176
+ this._onUnexpectedExit = fn;
177
+ }
173
178
  private watchdog?: ReturnType<typeof setInterval>;
179
+ private silenceKillTimer?: ReturnType<typeof setTimeout>;
174
180
  private pendingReplies = new Map<
175
181
  string,
176
182
  { resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout> }
@@ -223,9 +229,18 @@ export class KernelClient {
223
229
  kc.child = child;
224
230
  kc.connectionFilePath = connPath;
225
231
  child.on("exit", () => {
226
- // --- a dead kernel settles the running cell; the engine rebuilds ---
232
+ // --- a dead kernel settles the running cell; the engine rebuilds. clear child/ready
233
+ // --- so isRunning reflects death and the engine never resumes a zombie process. ---
227
234
  kc.settleActive(new Error("kernel process exited"));
228
- kc.onUnexpectedExit?.();
235
+ kc.child = undefined;
236
+ kc.ready = false;
237
+ kc.shell?.close();
238
+ kc.control?.close();
239
+ kc.iopub?.close();
240
+ kc.shell = undefined;
241
+ kc.control = undefined;
242
+ kc.iopub = undefined;
243
+ kc._onUnexpectedExit?.();
229
244
  });
230
245
 
231
246
  try {
@@ -494,6 +509,12 @@ export class KernelClient {
494
509
  if (quiet >= this.timeoutMs && !active.settled) {
495
510
  active.timedOut = true;
496
511
  this.interrupt();
512
+ // --- the interrupt is a real KeyboardInterrupt, but a cell that swallows/ignores
513
+ // --- it never replies; escalate to a kill so the queue is freed, mirroring index.ts. ---
514
+ this.silenceKillTimer ??= setTimeout(() => {
515
+ if (!active.settled) this.kill();
516
+ }, SILENCE_KILL_GRACE_MS);
517
+ this.silenceKillTimer.unref?.();
497
518
  }
498
519
  },
499
520
  Math.min(250, this.timeoutMs),
@@ -506,6 +527,10 @@ export class KernelClient {
506
527
  clearInterval(this.watchdog);
507
528
  this.watchdog = undefined;
508
529
  }
530
+ if (this.silenceKillTimer) {
531
+ clearTimeout(this.silenceKillTimer);
532
+ this.silenceKillTimer = undefined;
533
+ }
509
534
  }
510
535
 
511
536
  /** Genuine KeyboardInterrupt via control-channel interrupt_request; the kernel survives. */
@@ -39,7 +39,7 @@ export interface RenderDeps {
39
39
  }
40
40
 
41
41
  const OUTPUT_INDENT = " ";
42
- const SPINNER_FRAMES = ["", "", "", ""];
42
+ const SPINNER_FRAMES = [">..", ".>.", "..>", ".>."];
43
43
 
44
44
  export function formatDuration(durationMs: number | undefined): string | undefined {
45
45
  if (durationMs === undefined) return undefined;
@@ -105,13 +105,68 @@ function marker(state: ExecuteRenderState, deps: RenderDeps): string {
105
105
  return deps.fg("success", "✓");
106
106
  case "running": {
107
107
  const now = deps.now?.() ?? Date.now();
108
- return deps.fg("accent", SPINNER_FRAMES[Math.floor(now / 160) % SPINNER_FRAMES.length]);
108
+ return deps.fg("accent", SPINNER_FRAMES[Math.floor(now / 120) % SPINNER_FRAMES.length]);
109
109
  }
110
110
  default:
111
111
  return deps.fg("muted", "◇");
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * highlight.js python emits no scope for plain identifiers, so they arrive as raw (uncolored)
117
+ * text mixed with SGR-colored tokens and bare punctuation. Re-color only whole identifier runs
118
+ * that sit outside any colored span, leaving keywords, strings, numbers, and other already
119
+ * colored tokens untouched.
120
+ */
121
+ function colorBareIdentifiers(line: string, paint: (id: string) => string): string {
122
+ if (!line.includes("\x1b") && !/[a-zA-Z_]/.test(line)) return line;
123
+ const out: string[] = [];
124
+ let pending = "";
125
+ let colored = false;
126
+ const pushRaw = (s: string) => {
127
+ let last = 0;
128
+ for (const m of s.matchAll(/[a-zA-Z_][a-zA-Z0-9_]*/g)) {
129
+ const index = m.index ?? 0;
130
+ out.push(s.slice(last, index), paint(m[0]));
131
+ last = index + m[0].length;
132
+ }
133
+ out.push(s.slice(last));
134
+ };
135
+ let i = 0;
136
+ const isFgColor = (seq: string) => /\x1b\[(?:38|9?[0-7])/.test(seq);
137
+ while (i < line.length) {
138
+ if (line[i] === "\x1b") {
139
+ const end = line.indexOf("m", i) + 1;
140
+ const seq = line.slice(i, end);
141
+ if (isFgColor(seq)) {
142
+ if (pending) {
143
+ pushRaw(pending);
144
+ pending = "";
145
+ }
146
+ out.push(seq);
147
+ colored = true;
148
+ } else if (seq.includes("39") || seq.includes("0m")) {
149
+ if (colored) {
150
+ out.push(seq);
151
+ colored = false;
152
+ } else {
153
+ pending += seq;
154
+ }
155
+ } else {
156
+ pending += seq;
157
+ }
158
+ i = end;
159
+ continue;
160
+ }
161
+ // only raw (uncolored) text may be repainted; colored tokens pass through untouched
162
+ if (colored) out.push(line[i]);
163
+ else pending += line[i];
164
+ i++;
165
+ }
166
+ if (pending) pushRaw(pending);
167
+ return out.join("");
168
+ }
169
+
115
170
  function highlightLines(code: string, deps: RenderDeps): string[] {
116
171
  if (!code) return [];
117
172
  return deps.highlight(code);
@@ -211,14 +266,15 @@ function addWrapped(
211
266
  text: string,
212
267
  width: number,
213
268
  deps: RenderDeps,
214
- options: { sanitize?: boolean } = {},
269
+ options: { sanitize?: boolean; indentAfter?: number } = {},
215
270
  ): void {
216
271
  const safe = options.sanitize === false ? text : sanitizeTuiOutput(text);
217
272
  const available = Math.max(1, width - 1 - deps.visibleWidth(prefix));
218
273
  const wrapped = deps.wrapTextWithAnsi(safe, available);
219
274
  for (const [index, line] of (wrapped.length > 0 ? wrapped : [""]).entries()) {
220
275
  const linePrefix = index === 0 ? prefix : " ".repeat(deps.visibleWidth(prefix));
221
- lines.push(deps.truncateToWidth(` ${linePrefix}${closeOpenSgr(line)}`, width, ""));
276
+ const continuationIndent = index > 0 && options.indentAfter ? " ".repeat(options.indentAfter) : "";
277
+ lines.push(deps.truncateToWidth(` ${linePrefix}${continuationIndent}${closeOpenSgr(line)}`, width, ""));
222
278
  }
223
279
  }
224
280
 
@@ -229,8 +285,13 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
229
285
  const highlighted = highlightLines(code, deps);
230
286
  for (const [index, rawLine] of code.split("\n").entries()) {
231
287
  const prefix = index === 0 ? deps.fg("dim", "› ") : deps.fg("dim", " ");
232
- // --- code is already highlighted; don't strip its ANSI ---
233
- addWrapped(lines, prefix, highlighted[index] ?? rawLine, width, deps, { sanitize: false });
288
+ const paint = (id: string) => deps.fg("syntaxVariable", id);
289
+ const hlLine = colorBareIdentifiers(highlighted[index] ?? rawLine, paint);
290
+ const indent = /^[ \t]*/.exec(rawLine)?.[0] ?? "";
291
+ addWrapped(lines, prefix, hlLine, width, deps, {
292
+ sanitize: false,
293
+ indentAfter: deps.visibleWidth(indent),
294
+ });
234
295
  }
235
296
  return true;
236
297
  }
@@ -43,7 +43,7 @@ function renderVersion(state: ExecuteRenderState): string {
43
43
  state.executionStarted,
44
44
  state.hasResult,
45
45
  // --- fold the animation frame in while running so the spinner still turns ---
46
- statusKind(state) === "running" ? Math.floor(Date.now() / 160) % 4 : -1,
46
+ statusKind(state) === "running" ? Math.floor(Date.now() / 120) % 4 : -1,
47
47
  ].join("|");
48
48
  }
49
49