@code-yeongyu/senpi-codemode 2026.10.18 → 2026.10.19

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/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.19] - 2026-10-19
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.10.18-1] - 2026-10-18
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ ### Fixed
36
+
37
+ - A Python kernel no longer loses its globals silently. When its control pipe broke before its exit was seen (a dying interpreter hit by the host's next write), the death went unreported and the next cell started a fresh interpreter with no notice; it is now a death like any other, replaced and announced. A kernel replaced by a later generation of the same session (a reload, including the automatic config reload, a host restart, or a resume) now announces itself on its first result. Restart notices name the lost globals; a SIGKILL that senpi did not send says it was probably the system under memory pressure and gives the host's memory state and the kernel's last memory reading; the prelude's parent watch names itself when it stops the kernel. Reported by 용짱 ([#3612](https://github.com/code-yeongyu/senpi/issues/3612)).
38
+
39
+ ### Removed
40
+
15
41
  ## [2026.10.18] - 2026-10-18
16
42
 
17
43
  ### Breaking Changes
package/README.md CHANGED
@@ -560,6 +560,21 @@ fresh interpreter dies too before it finished a cell, the queued cells fail with
560
560
  another. An interpreter whose exit cannot be confirmed is reported, never
561
561
  replaced by a second one. The JavaScript worker keeps its own restart path.
562
562
 
563
+ A death is reported however the host first hears of it: a broken control pipe
564
+ (EPIPE) counts the same as the exit. A SIGKILL senpi did not send reads
565
+ `signal 9, probably killed by the system, most often under memory pressure`,
566
+ with the host's memory state when it can be read (macOS memory pressure level
567
+ and swap in use; Linux available memory and swap in use) and, for Python, the
568
+ kernel's last memory reading. The Python prelude's parent watch names itself
569
+ instead when it stops the kernel. Python results report the user globals
570
+ defined so far (`globalNames`, at most 64, and `globalCount`); the notice lists
571
+ the ones a dead kernel took with it (`Lost: helper, rows`). The session keeps
572
+ that list in `<artifacts>/kernels/py-globals.json`, so the first result of a
573
+ kernel started by a later generation of the same session (a reload, including
574
+ the automatic config reload, a host restart, or a resume) says
575
+ `[py kernel is new: ...; every global it held is gone. Lost: ...]`. A reset
576
+ clears the list.
577
+
563
578
  Commands a cell runs through `Bun.$` never read the host's terminal: the worker
564
579
  thread shares the TUI's stdin, so the shell wrapper hands every template an
565
580
  empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.18",
3
+ "version": "2026.10.19",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.18",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.19",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.18"
41
+ "@code-yeongyu/senpi": "2026.10.19"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.18"
44
+ "@code-yeongyu/senpi": "2026.10.19"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -100,6 +100,12 @@ const hostToKernelMessageSchema = Type.Union([
100
100
  */
101
101
  const kernelStateSchema = Type.Union([Type.Literal("lost"), Type.Literal("restarted"), Type.Literal("not-run")]);
102
102
 
103
+ /** Python results: the user globals defined after the cell, so a notice can name what a lost kernel took with it. */
104
+ const resultGlobalsSchema = {
105
+ globalCount: Type.Optional(Type.Integer({ minimum: 0 })),
106
+ globalNames: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { maxItems: 64 })),
107
+ };
108
+
103
109
  const kernelToHostMessageSchema = Type.Union([
104
110
  Type.Object({ type: Type.Literal("ready"), memoryGlobals: Type.Optional(Type.Boolean()) }),
105
111
  Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
@@ -129,6 +135,7 @@ const kernelToHostMessageSchema = Type.Union([
129
135
  valueRepr: Type.Optional(Type.String()),
130
136
  durationMs: Type.Integer({ minimum: 0 }),
131
137
  memory: Type.Optional(kernelMemoryReportSchema),
138
+ ...resultGlobalsSchema,
132
139
  /** Host-set: the bracketed notice that this cell ran on a kernel restarted after its interpreter died. */
133
140
  notice: Type.Optional(Type.String()),
134
141
  kernelState: Type.Optional(kernelStateSchema),
@@ -140,6 +147,7 @@ const kernelToHostMessageSchema = Type.Union([
140
147
  error: bridgeErrorSchema,
141
148
  durationMs: Type.Integer({ minimum: 0 }),
142
149
  memory: Type.Optional(kernelMemoryReportSchema),
150
+ ...resultGlobalsSchema,
143
151
  notice: Type.Optional(Type.String()),
144
152
  kernelState: Type.Optional(kernelStateSchema),
145
153
  }),
@@ -0,0 +1,101 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { LostGlobals } from "../kernels/shared/kernel-death.ts";
4
+ import type { EvalKernelResult, EvalLanguage } from "../tool/types.ts";
5
+
6
+ const NONE: LostGlobals = { names: [], count: 0 };
7
+
8
+ /**
9
+ * The user globals a session's kernel holds, as its newest result reported them, kept in memory and in
10
+ * the session's artifacts directory. A death names them in its restart notice; a later session-manager
11
+ * generation (reload, host restart, resume) reads the file and names what its predecessor took with it.
12
+ * The file is a diagnostic: when it cannot be read or written the notice names fewer globals, nothing else.
13
+ */
14
+ export class KernelGlobalsLedger {
15
+ readonly #path: string | undefined;
16
+ #current: LostGlobals = NONE;
17
+ #inherited: LostGlobals;
18
+ #writes: Promise<void> = Promise.resolve();
19
+ #written = "";
20
+
21
+ private constructor(path: string | undefined, inherited: LostGlobals) {
22
+ this.#path = path;
23
+ this.#inherited = inherited;
24
+ this.#written = serialize(inherited);
25
+ }
26
+
27
+ static inMemory(): KernelGlobalsLedger {
28
+ return new KernelGlobalsLedger(undefined, NONE);
29
+ }
30
+
31
+ static async open(artifactsDir: string | undefined, language: EvalLanguage): Promise<KernelGlobalsLedger> {
32
+ if (artifactsDir === undefined) return KernelGlobalsLedger.inMemory();
33
+ const path = join(artifactsDir, "kernels", `${language}-globals.json`);
34
+ return new KernelGlobalsLedger(path, await readLedger(path));
35
+ }
36
+
37
+ /** What an earlier kernel of this session held when it ended, once; empty when it held nothing. */
38
+ takeInherited(): LostGlobals {
39
+ const inherited = this.#inherited;
40
+ this.#inherited = NONE;
41
+ return inherited;
42
+ }
43
+
44
+ takeLost(): LostGlobals {
45
+ const lost = this.#current;
46
+ this.#record(NONE);
47
+ return lost;
48
+ }
49
+
50
+ observe(result: EvalKernelResult): void {
51
+ if (result.globalNames === undefined) return;
52
+ this.#record({ names: result.globalNames, count: result.globalCount ?? result.globalNames.length });
53
+ }
54
+
55
+ /** A reset discards every global on purpose: nothing is lost that a later notice should name. */
56
+ clear(): void {
57
+ this.#inherited = NONE;
58
+ this.#record(NONE);
59
+ }
60
+
61
+ flush(): Promise<void> {
62
+ return this.#writes;
63
+ }
64
+
65
+ #record(globals: LostGlobals): void {
66
+ this.#current = globals;
67
+ const path = this.#path;
68
+ const text = serialize(globals);
69
+ if (path === undefined || text === this.#written) return;
70
+ this.#written = text;
71
+ this.#writes = this.#writes.then(() => writeLedger(path, text)).catch(() => undefined);
72
+ }
73
+ }
74
+
75
+ function serialize(globals: LostGlobals): string {
76
+ return JSON.stringify({ names: globals.names, count: globals.count });
77
+ }
78
+
79
+ async function writeLedger(path: string, text: string): Promise<void> {
80
+ await mkdir(dirname(path), { recursive: true });
81
+ const staging = `${path}.${process.pid}.tmp`;
82
+ await writeFile(staging, text);
83
+ await rename(staging, path);
84
+ }
85
+
86
+ async function readLedger(path: string): Promise<LostGlobals> {
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(await readFile(path, "utf8"));
90
+ } catch {
91
+ return NONE;
92
+ }
93
+ if (typeof parsed !== "object" || parsed === null) return NONE;
94
+ const names = "names" in parsed && Array.isArray(parsed.names) ? parsed.names.filter(isName) : [];
95
+ const count = "count" in parsed && typeof parsed.count === "number" ? parsed.count : names.length;
96
+ return { names, count: Math.max(count, names.length) };
97
+ }
98
+
99
+ function isName(value: unknown): value is string {
100
+ return typeof value === "string" && value.length > 0;
101
+ }
@@ -1,7 +1,10 @@
1
1
  import type { HostToKernelMessage } from "../bridge/protocol.ts";
2
+ import { type HostMemoryReader, readHostMemoryState } from "../kernels/shared/host-memory-state.ts";
2
3
  import {
3
4
  type KernelLifecycle,
4
5
  KernelUnavailableError,
6
+ type LostGlobals,
7
+ replacedKernelNotice,
5
8
  restartNotice,
6
9
  unstartedResult,
7
10
  } from "../kernels/shared/kernel-death.ts";
@@ -13,10 +16,21 @@ import type {
13
16
  KernelInterruptHandle,
14
17
  PendingCell,
15
18
  } from "../tool/types.ts";
19
+ import { KernelGlobalsLedger } from "./kernel-globals-ledger.ts";
16
20
  import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
17
21
 
18
22
  export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
19
23
 
24
+ export interface ReplaceableKernelOptions {
25
+ /** Names the globals a death or an earlier generation of the session took with it. */
26
+ readonly ledger?: KernelGlobalsLedger;
27
+ /** Read when a SIGKILL nobody in senpi sent explains a death; the default asks the OS. */
28
+ readonly readHostMemory?: HostMemoryReader;
29
+ }
30
+
31
+ /** An unlabeled SIGKILL reaching a live host came from outside senpi: on a desktop, most often the OS under memory pressure. */
32
+ const SIGKILL_REASON = "signal 9";
33
+
20
34
  class ReplaceableKernelClosedError extends Error {
21
35
  readonly name = "ReplaceableKernelClosedError";
22
36
 
@@ -50,14 +64,25 @@ export class ReplaceableKernel implements EvalKernel {
50
64
  #announce: string | null = null;
51
65
  #closed = false;
52
66
  #closeFailure: unknown;
67
+ readonly #ledger: KernelGlobalsLedger;
68
+ readonly #readHostMemory: HostMemoryReader;
69
+ /** What the instance held when it died, named by the replacement's notice. */
70
+ #lostAtDeath: LostGlobals | undefined;
53
71
 
54
72
  // Present only when the instance has kernel tools, so a probe sees what it would see on the instance itself.
55
73
  declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
56
74
  declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
57
75
 
58
- private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
76
+ private constructor(
77
+ language: EvalLanguage,
78
+ start: StartKernel,
79
+ first: EvalKernel,
80
+ options: ReplaceableKernelOptions,
81
+ ) {
59
82
  this.#language = language;
60
83
  this.#start = start;
84
+ this.#ledger = options.ledger ?? KernelGlobalsLedger.inMemory();
85
+ this.#readHostMemory = options.readHostMemory ?? readHostMemoryState;
61
86
  this.#kernel = first;
62
87
  // Python is the one subprocess kernel with kernel tools; rb and jl only carry tools_unavailable stubs to hide.
63
88
  if (language === "py" && hasKernelTools(first)) {
@@ -77,7 +102,11 @@ export class ReplaceableKernel implements EvalKernel {
77
102
  }
78
103
 
79
104
  /** Starts the first instance; one that cannot hand back its queue is returned as it is. */
80
- static async create(language: EvalLanguage, start: StartKernel): Promise<EvalKernel> {
105
+ static async create(
106
+ language: EvalLanguage,
107
+ start: StartKernel,
108
+ options: ReplaceableKernelOptions = {},
109
+ ): Promise<EvalKernel> {
81
110
  let owner: ReplaceableKernel | undefined;
82
111
  const first = await start({
83
112
  onDeath: (reason) => {
@@ -85,7 +114,7 @@ export class ReplaceableKernel implements EvalKernel {
85
114
  },
86
115
  });
87
116
  if (first.drainPending === undefined) return first;
88
- owner = new ReplaceableKernel(language, start, first);
117
+ owner = new ReplaceableKernel(language, start, first, options);
89
118
  return owner;
90
119
  }
91
120
 
@@ -123,6 +152,7 @@ export class ReplaceableKernel implements EvalKernel {
123
152
  }
124
153
 
125
154
  async reset(): Promise<void> {
155
+ this.#ledger.clear();
126
156
  if (this.#replacing !== null) await this.#replacing;
127
157
  if (this.#kernel.isAlive?.() === false) {
128
158
  // A reset discards every global anyway, so the fresh instance announces nothing.
@@ -144,6 +174,7 @@ export class ReplaceableKernel implements EvalKernel {
144
174
  if (this.#replacing !== null) await this.#replacing;
145
175
  if (this.#closeFailure !== undefined) throw this.#closeFailure;
146
176
  await this.#kernel.close();
177
+ await this.#ledger.flush();
147
178
  }
148
179
 
149
180
  listKernelToolNames(): readonly string[] {
@@ -159,7 +190,10 @@ export class ReplaceableKernel implements EvalKernel {
159
190
  if (kernel.isAlive?.() === false)
160
191
  return result.kernelState === undefined ? { ...result, kernelState: "lost" } : result;
161
192
  this.#recovering = null;
162
- const notice = this.#announce;
193
+ this.#ledger.observe(result);
194
+ const inherited = this.#ledger.takeInherited();
195
+ const notice =
196
+ this.#announce ?? (inherited.count > 0 ? replacedKernelNotice(this.#language, inherited) : null);
163
197
  if (notice === null) return result;
164
198
  this.#announce = null;
165
199
  return { ...result, notice, kernelState: "restarted" };
@@ -169,6 +203,7 @@ export class ReplaceableKernel implements EvalKernel {
169
203
  #died(generation: number, reason: string): void {
170
204
  if (generation !== this.#generation || this.#closed) return;
171
205
  this.#held = [...(this.#kernel.drainPending?.() ?? []), ...this.#held];
206
+ this.#lostAtDeath = this.#ledger.takeLost();
172
207
  if (this.#recovering !== null) {
173
208
  this.#recovering = null;
174
209
  this.#unannounced = reason;
@@ -194,6 +229,9 @@ export class ReplaceableKernel implements EvalKernel {
194
229
  /** Never rejects: every failure settles the held cells with what went wrong. */
195
230
  async #replace(reason: string | null): Promise<void> {
196
231
  const dead = this.#kernel;
232
+ // Composed while the old interpreter retires, and before the replacement starts: a death of the
233
+ // replacement must find it already current, with no await between its start and its adoption.
234
+ const announcing = reason === null ? Promise.resolve(null) : this.#restartNotice(reason);
197
235
  try {
198
236
  await dead.close();
199
237
  } catch (error) {
@@ -201,6 +239,7 @@ export class ReplaceableKernel implements EvalKernel {
201
239
  this.#failUnavailable(reason, `its interpreter could not be retired: ${errorText(error)}`);
202
240
  return;
203
241
  }
242
+ const announce = await announcing;
204
243
  if (this.#closed) return;
205
244
  const generation = ++this.#nextGeneration;
206
245
  let next: EvalKernel;
@@ -218,7 +257,7 @@ export class ReplaceableKernel implements EvalKernel {
218
257
  }
219
258
  this.#kernel = next;
220
259
  this.#generation = generation;
221
- this.#announce = reason === null ? null : restartNotice(this.#language, reason);
260
+ this.#announce = announce;
222
261
  for (const cell of this.#held.splice(0)) {
223
262
  void this.#submit(generation, next, cell.input).then(cell.settle, (error: unknown) =>
224
263
  cell.settle(unstartedResult(cell.input.cellId, errorText(error))),
@@ -226,6 +265,15 @@ export class ReplaceableKernel implements EvalKernel {
226
265
  }
227
266
  }
228
267
 
268
+ async #restartNotice(reason: string): Promise<string> {
269
+ const lost = this.#lostAtDeath;
270
+ this.#lostAtDeath = undefined;
271
+ if (!reason.startsWith(SIGKILL_REASON)) return restartNotice(this.#language, reason, lost);
272
+ const memory = await this.#readHostMemory();
273
+ const cause = `probably killed by the system, most often under memory pressure${memory === undefined ? "" : ` (${memory})`}`;
274
+ return restartNotice(this.#language, `${reason}, ${cause}`, lost);
275
+ }
276
+
229
277
  #failUnavailable(reason: string | null, detail: string): void {
230
278
  this.#recovering = null;
231
279
  this.#unannounced = reason;
@@ -16,6 +16,7 @@ import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-d
16
16
  import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
17
17
  import { CellKernelTools } from "./cell-kernel-tools.ts";
18
18
  import { parkWhenIdle } from "./idle-parking-kernel.ts";
19
+ import { KernelGlobalsLedger } from "./kernel-globals-ledger.ts";
19
20
  import {
20
21
  javaScriptKernelMemory,
21
22
  SessionKernelRegistrations,
@@ -69,6 +70,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
69
70
  readonly #registrations: SessionKernelRegistrations;
70
71
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
71
72
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
73
+ /** One per language for the manager's life, so an idle-park restart never re-announces an earlier generation. */
74
+ readonly #ledgers = new Map<EvalLanguage, Promise<KernelGlobalsLedger>>();
72
75
  readonly #cellKernelTools = new CellKernelTools();
73
76
  #context: ExtensionContext | undefined;
74
77
  #generation = 0;
@@ -231,11 +234,21 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
231
234
  ): Promise<{ readonly kernel: EvalKernel; readonly memory: RegisteredKernelSource | undefined }> {
232
235
  if (language === "js") return await this.#createKernel(language, onMessage);
233
236
  let memory: RegisteredKernelSource | undefined;
234
- const kernel = await ReplaceableKernel.create(language, async (lifecycle) => {
235
- const created = await this.#createKernel(language, onMessage, lifecycle);
236
- memory ??= created.memory;
237
- return created.kernel;
238
- });
237
+ let ledger = this.#ledgers.get(language);
238
+ if (ledger === undefined) {
239
+ ledger = KernelGlobalsLedger.open(this.#options.artifactsDir, language);
240
+ this.#ledgers.set(language, ledger);
241
+ }
242
+ const options = { ledger: await ledger };
243
+ const kernel = await ReplaceableKernel.create(
244
+ language,
245
+ async (lifecycle) => {
246
+ const created = await this.#createKernel(language, onMessage, lifecycle);
247
+ memory ??= created.memory;
248
+ return created.kernel;
249
+ },
250
+ options,
251
+ );
239
252
  return { kernel, memory };
240
253
  }
241
254
 
@@ -0,0 +1,29 @@
1
+ import type { KernelMemoryReport } from "../../bridge/memory-protocol.ts";
2
+ import { describeExit } from "../shared/kernel-death.ts";
3
+ import { formatBytes } from "../shared/kernel-memory.ts";
4
+
5
+ /** Written by the prelude's parent watch (prelude.py `PARENT_WATCH_MARKER`) just before it kills its own group. */
6
+ const PARENT_WATCH_MARKER = "[senpi] parent watch:";
7
+
8
+ export interface PythonExit {
9
+ readonly code: number | null;
10
+ readonly signal: string | null;
11
+ }
12
+
13
+ /**
14
+ * Why an interpreter the host did not retire is gone, for the restart notice: the prelude's own parent
15
+ * watch names itself on stderr; anything else is its exit status, with the last memory the kernel
16
+ * reported so a reader can tell whether its own size could explain a kill by the system.
17
+ */
18
+ export function pythonDeathReason(stderrTail: string, exit: PythonExit, lastMemory?: KernelMemoryReport): string {
19
+ const watch = stderrTail
20
+ .split("\n")
21
+ .find((line) => line.startsWith(PARENT_WATCH_MARKER))
22
+ ?.slice(PARENT_WATCH_MARKER.length)
23
+ .trim();
24
+ if (watch !== undefined) return `senpi's parent watch stopped it (${watch})`;
25
+ const status = describeExit(exit.code, exit.signal);
26
+ if (lastMemory === undefined) return status;
27
+ const measure = lastMemory.measure === "heap" ? "live after GC" : "in its process footprint";
28
+ return `${status} (its last reading was ${formatBytes(lastMemory.liveBytes)} ${measure})`;
29
+ }
@@ -6,9 +6,9 @@ import type {
6
6
  KernelToolsInvokeRequest,
7
7
  } from "../js/kernel-tools-types.ts";
8
8
  import { inputAtStart } from "../shared/cell-source-at-start.ts";
9
- import { describeExit } from "../shared/kernel-death.ts";
10
9
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
11
10
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
11
+ import { pythonDeathReason } from "./death-reason.ts";
12
12
  import { runHostCell } from "./host-cell.ts";
13
13
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
14
14
  import { PythonKernelTools } from "./kernel-tools-host.ts";
@@ -41,6 +41,8 @@ export class PythonKernel {
41
41
  #failure: Error | null = null;
42
42
  readonly #preludes = new KernelPreludeTracker();
43
43
  readonly #memory: KernelMemoryHost | null;
44
+ /** The newest reading the interpreter reported, named by a restart notice if it dies. */
45
+ #lastMemory: ResultMessage["memory"];
44
46
  #generation = 0;
45
47
  #closed = false;
46
48
  readonly #tools: PythonKernelTools;
@@ -313,6 +315,7 @@ export class PythonKernel {
313
315
  async #spawn(generation: number): Promise<void> {
314
316
  if (this.#closed || generation !== this.#generation) throw new Error("Python kernel startup was superseded");
315
317
  this.#memory?.processReplaced();
318
+ this.#lastMemory = undefined;
316
319
  this.#transport = await PythonKernelTransport.start({
317
320
  ...this.#options,
318
321
  onMessage: (message) => {
@@ -334,12 +337,14 @@ export class PythonKernel {
334
337
  },
335
338
  onResult: (transport, result) => this.#onResult(transport, result),
336
339
  onError: (transport, error) => this.#onError(transport, error),
337
- onExit: (transport, error, exit) => this.#onExit(transport, error, describeExit(exit.code, exit.signal)),
340
+ onExit: (transport, error, exit) =>
341
+ this.#onExit(transport, error, pythonDeathReason(error.message, exit, this.#lastMemory)),
338
342
  });
339
343
  }
340
344
 
341
345
  #onResult(transport: PythonKernelTransport, result: ResultMessage): void {
342
346
  if (this.#transport !== transport) return;
347
+ if (result.memory !== undefined) this.#lastMemory = result.memory;
343
348
  const pending = this.#pending.get(result.cellId);
344
349
  if (pending) {
345
350
  const annotated = this.#memory?.annotate(result) ?? result;
@@ -403,11 +408,16 @@ export class PythonKernel {
403
408
  );
404
409
  },
405
410
  );
411
+ // With an owner, the retired interpreter is a death like an exit (senpi#3612): replaced, and announced.
412
+ const onDeath = this.#options.onDeath;
413
+ if (onDeath) this.#dead = true;
406
414
  const active = this.#active;
407
415
  if (active) {
408
416
  if (active.resolveStateRetained) active.resolveStateRetained(false);
409
- this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
417
+ const message = onDeath ? "Python kernel died; every global is lost" : "Python kernel died";
418
+ this.#settleRun(active, failedPythonResult(active.input.cellId, message, error.message));
410
419
  }
420
+ onDeath?.(`a failure of its control pipe (${error.message})`);
411
421
  }
412
422
 
413
423
  #settleRun(pending: PendingRun, result: ResultMessage): void {
@@ -2172,6 +2172,12 @@ async def run_code(code: Any, want_value: bool) -> Any:
2172
2172
  return None
2173
2173
 
2174
2174
 
2175
+ # Globals a tool prelude installs: the host reinstalls them on a fresh kernel, so they are never reported as lost.
2176
+ _PRELUDE_EXPORTS: set[str] = set()
2177
+ # The host keeps the newest list so a notice can name what a dead or replaced kernel took with it.
2178
+ _REPORTED_GLOBAL_NAMES = 64
2179
+
2180
+
2175
2181
  def apply_preludes(preludes: Any) -> None:
2176
2182
  # Host-computed per cell: globals of tools deactivated since the last cell are dropped,
2177
2183
  # and an active tool's snippet runs only while one of its exports is missing.
@@ -2179,11 +2185,22 @@ def apply_preludes(preludes: Any) -> None:
2179
2185
  return
2180
2186
  for name in preludes.get("remove", []):
2181
2187
  USER_NS.pop(name, None)
2188
+ _PRELUDE_EXPORTS.discard(name)
2182
2189
  for contribution in preludes.get("install", []):
2183
- if any(name not in USER_NS for name in contribution.get("exports", [])):
2190
+ exports = contribution.get("exports", [])
2191
+ _PRELUDE_EXPORTS.update(exports)
2192
+ if any(name not in USER_NS for name in exports):
2184
2193
  exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
2185
2194
 
2186
2195
 
2196
+ def _user_global_names() -> list[str]:
2197
+ return sorted(
2198
+ name
2199
+ for name in list(USER_NS)
2200
+ if name not in _MEMORY_BASELINE and name not in _PRELUDE_EXPORTS and not name.startswith("__")
2201
+ )
2202
+
2203
+
2187
2204
  def _enter_source_file(source_file: str | None) -> Callable[[], None]:
2188
2205
  # A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
2189
2206
  # so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
@@ -2272,6 +2289,9 @@ def run_cell(
2272
2289
  text("stderr", f"[senpi] kernel memory measurement failed: {exc}\n")
2273
2290
  if memory is not None:
2274
2291
  result["memory"] = memory
2292
+ names = _user_global_names()
2293
+ result["globalCount"] = len(names)
2294
+ result["globalNames"] = names[:_REPORTED_GLOBAL_NAMES]
2275
2295
  emit(result)
2276
2296
 
2277
2297
 
@@ -2334,12 +2354,21 @@ def _activate_env_root(root: str) -> None:
2334
2354
  importlib.invalidate_caches()
2335
2355
 
2336
2356
 
2337
- def _terminate_process_group() -> None:
2357
+ PARENT_WATCH_MARKER = "[senpi] parent watch:"
2358
+
2359
+
2360
+ def _terminate_process_group(reason: str) -> None:
2338
2361
  # The kernel is spawned into its own session (setsid), so its pid is its process
2339
2362
  # group id and a cell's subprocesses inherit that group. Killing the group takes
2340
2363
  # those children down with the kernel instead of orphaning them to init.
2364
+ # The reason goes to the real stderr first: a host that is still alive names it in its restart notice.
2341
2365
  if os.name != "posix":
2342
2366
  return
2367
+ stream = sys.__stderr__
2368
+ if stream is not None:
2369
+ with contextlib.suppress(OSError, ValueError):
2370
+ stream.write(f"{PARENT_WATCH_MARKER} {reason}; stopping the kernel\n")
2371
+ stream.flush()
2343
2372
  with contextlib.suppress(OSError):
2344
2373
  os.killpg(os.getpgrp(), signal.SIGKILL)
2345
2374
 
@@ -2350,8 +2379,9 @@ def _watch_parent(initial_ppid: int) -> None:
2350
2379
  # daemon thread notices the reparenting instead and takes the whole group down.
2351
2380
  while True:
2352
2381
  time.sleep(1.0)
2353
- if os.getppid() != initial_ppid:
2354
- _terminate_process_group()
2382
+ current_ppid = os.getppid()
2383
+ if current_ppid != initial_ppid:
2384
+ _terminate_process_group(f"its parent process changed from {initial_ppid} to {current_ppid}")
2355
2385
  return
2356
2386
 
2357
2387
 
@@ -2366,7 +2396,7 @@ def _watch_named_parent(parent_pid: int) -> None:
2366
2396
  try:
2367
2397
  os.kill(parent_pid, 0)
2368
2398
  except ProcessLookupError:
2369
- _terminate_process_group()
2399
+ _terminate_process_group(f"its host process {parent_pid} is gone")
2370
2400
  return
2371
2401
 
2372
2402
 
@@ -2422,7 +2452,7 @@ def main() -> None:
2422
2452
  # Reaching here without a close frame means the host's pipe hit EOF: it is gone,
2423
2453
  # so retire any subprocess the last cell left running before the interpreter exits.
2424
2454
  if not host_closed:
2425
- _terminate_process_group()
2455
+ _terminate_process_group("its host closed the control pipe")
2426
2456
 
2427
2457
 
2428
2458
  if __name__ == "__main__":
@@ -70,6 +70,8 @@ export interface PythonTransportOptions {
70
70
  }
71
71
 
72
72
  const hardKillWaitMs = 500;
73
+ /** How long a broken control pipe waits for the interpreter's exit, which carries the real cause of the death. */
74
+ const pipeErrorExitWaitMs = 1_000;
73
75
 
74
76
  export interface PythonPreludePathOptions extends CodemodeRuntimeAssetEnvironment {
75
77
  readonly localPath?: string;
@@ -95,6 +97,7 @@ export class PythonKernelTransport {
95
97
  #retirement: Promise<void> | null = null;
96
98
  #gone: Promise<void> | null = null;
97
99
  #isGone = false;
100
+ #pipeErrorTimer: NodeJS.Timeout | null = null;
98
101
 
99
102
  private constructor(options: PythonTransportOptions, child: KernelChild) {
100
103
  this.#options = options;
@@ -245,9 +248,11 @@ export class PythonKernelTransport {
245
248
  this.#child.on("error", onError);
246
249
  this.#child.on("exit", onExit);
247
250
  // senpi#3016: a frame written while the interpreter is dying fails on the stdin stream (EPIPE), not on the child.
248
- // #onError already ignores it once the child has exited or the transport is inactive, and the listener is never
249
- // detached, so a late write failure is never unhandled.
250
- this.#child.stdin.on("error", onError);
251
+ // The listener is never detached, so a late write failure is never unhandled. senpi#3612: that failure can be
252
+ // processed before the child's exit; the exit then reports the death, with its signal.
253
+ this.#child.stdin.on("error", (error: unknown) =>
254
+ this.#onPipeError(error instanceof Error ? error : new Error(String(error))),
255
+ );
251
256
  const { sessionId, connection, memory, kernelGeneration } = this.#options;
252
257
  this.#write({
253
258
  type: "init",
@@ -305,6 +310,7 @@ export class PythonKernelTransport {
305
310
  #onExit(code: number | null, signal: string | null): void {
306
311
  if (this.#exited) return;
307
312
  this.#exited = true;
313
+ this.#clearPipeErrorTimer();
308
314
  const active = this.#active;
309
315
  this.#active = false;
310
316
  const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
@@ -315,10 +321,27 @@ export class PythonKernelTransport {
315
321
 
316
322
  #onError(error: Error): void {
317
323
  if (this.#exited || !this.#active) return;
324
+ this.#clearPipeErrorTimer();
318
325
  this.#active = false;
319
326
  if (!this.#settleStartup(error)) this.#options.onError(this, error);
320
327
  }
321
328
 
329
+ /** A broken control pipe means the interpreter is gone or going: its exit, not this error, reports the death. */
330
+ #onPipeError(error: Error): void {
331
+ if (this.#exited || !this.#active || this.#pipeErrorTimer !== null) return;
332
+ this.#pipeErrorTimer = setTimeout(() => {
333
+ this.#pipeErrorTimer = null;
334
+ this.#onError(error);
335
+ }, pipeErrorExitWaitMs);
336
+ this.#pipeErrorTimer.unref?.();
337
+ }
338
+
339
+ #clearPipeErrorTimer(): void {
340
+ if (this.#pipeErrorTimer === null) return;
341
+ clearTimeout(this.#pipeErrorTimer);
342
+ this.#pipeErrorTimer = null;
343
+ }
344
+
322
345
  #detachListeners(): void {
323
346
  const detach = this.#detachChildListeners;
324
347
  if (!detach) return;
@@ -0,0 +1,65 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { formatBytes } from "./kernel-memory.ts";
4
+
5
+ const READ_TIMEOUT_MS = 1_000;
6
+ const MIB = 1024 * 1024;
7
+ const KIB = 1024;
8
+
9
+ /** kern.memorystatus_vm_pressure_level: the kernel's own verdict, the one jetsam acts on. */
10
+ const DARWIN_PRESSURE_LEVELS: Readonly<Record<string, string>> = { "1": "normal", "2": "warning", "4": "critical" };
11
+
12
+ export type HostMemoryReader = () => Promise<string | undefined>;
13
+
14
+ /**
15
+ * The host's memory state in a few words, for a notice about a kernel the system may have killed:
16
+ * macOS memory pressure and swap in use, Linux available memory and swap in use. `undefined` when the
17
+ * platform has no reading or it could not be taken in time; the notice then simply says less.
18
+ */
19
+ export const readHostMemoryState: HostMemoryReader = async () => {
20
+ try {
21
+ if (process.platform === "darwin") return describeDarwin(await sysctl());
22
+ if (process.platform === "linux") return describeLinux(await readFile("/proc/meminfo", "utf8"));
23
+ return undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ };
28
+
29
+ function sysctl(): Promise<string> {
30
+ return new Promise((resolve, reject) => {
31
+ execFile(
32
+ "sysctl",
33
+ ["-n", "kern.memorystatus_vm_pressure_level", "vm.swapusage"],
34
+ { encoding: "utf8", timeout: READ_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C" } },
35
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
36
+ );
37
+ });
38
+ }
39
+
40
+ export function describeDarwin(output: string): string | undefined {
41
+ const [levelLine = "", swapLine = ""] = output.trim().split("\n");
42
+ const parts: string[] = [];
43
+ const level = DARWIN_PRESSURE_LEVELS[levelLine.trim()];
44
+ if (level !== undefined) parts.push(`system memory pressure ${level}`);
45
+ const used = /used = ([\d.]+)M/u.exec(swapLine)?.[1];
46
+ if (used !== undefined) parts.push(`swap used ${formatBytes(Number(used) * MIB)}`);
47
+ return parts.length === 0 ? undefined : parts.join(", ");
48
+ }
49
+
50
+ export function describeLinux(meminfo: string): string | undefined {
51
+ const kib = (key: string): number | undefined => {
52
+ const value = new RegExp(`^${key}:\\s+(\\d+) kB$`, "mu").exec(meminfo)?.[1];
53
+ return value === undefined ? undefined : Number(value) * KIB;
54
+ };
55
+ const available = kib("MemAvailable");
56
+ const total = kib("MemTotal");
57
+ const swapTotal = kib("SwapTotal");
58
+ const swapFree = kib("SwapFree");
59
+ const parts: string[] = [];
60
+ if (available !== undefined && total !== undefined)
61
+ parts.push(`${formatBytes(available)} of ${formatBytes(total)} memory available`);
62
+ if (swapTotal !== undefined && swapFree !== undefined && swapTotal > 0)
63
+ parts.push(`swap used ${formatBytes(swapTotal - swapFree)}`);
64
+ return parts.length === 0 ? undefined : parts.join(", ");
65
+ }
@@ -16,8 +16,28 @@ export function describeExit(code: number | null, signal: string | null): string
16
16
  return code === null ? "an exit with no status" : `exit code ${code}`;
17
17
  }
18
18
 
19
- export function restartNotice(language: EvalLanguage, reason: string): string {
20
- return `[${language} kernel was restarted after ${reason}; every global is lost]`;
19
+ /** Globals a lost kernel held, as its last result reported them: their names and how many there were in all. */
20
+ export interface LostGlobals {
21
+ readonly names: readonly string[];
22
+ readonly count: number;
23
+ }
24
+
25
+ const NAMED_LOST_GLOBALS = 20;
26
+
27
+ export function restartNotice(language: EvalLanguage, reason: string, lost?: LostGlobals): string {
28
+ return `[${language} kernel was restarted after ${reason}; every global is lost${lostPhrase(lost)}]`;
29
+ }
30
+
31
+ /** The first result of a session's kernel when an earlier kernel of the same session ended with globals defined. */
32
+ export function replacedKernelNotice(language: EvalLanguage, lost: LostGlobals): string {
33
+ return `[${language} kernel is new: this session's previous ${language} kernel ended (session reload, host restart, or resume), so every global it held is gone${lostPhrase(lost)}]`;
34
+ }
35
+
36
+ function lostPhrase(lost: LostGlobals | undefined): string {
37
+ if (lost === undefined || lost.count === 0) return "";
38
+ const named = lost.names.slice(0, NAMED_LOST_GLOBALS);
39
+ const more = lost.count - named.length;
40
+ return `. Lost: ${named.join(", ")}${more > 0 ? ` and ${more} more` : ""}`;
21
41
  }
22
42
 
23
43
  export const EVAL_KERNEL_UNAVAILABLE = "eval_kernel_unavailable";