@code-yeongyu/senpi-codemode 2026.9.13-2 → 2026.9.15-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/CHANGELOG.md +29 -0
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +62 -13
- package/src/kernels/js/process-tree-host.ts +158 -0
- package/src/kernels/js/process-tree.d.ts +24 -0
- package/src/kernels/js/process-tree.js +151 -0
- package/src/kernels/js/worker-core.js +3 -1
- package/src/kernels/js/worker-runtime.js +35 -7
- package/src/kernels/js/worker-shell-capture.d.ts +2 -1
- package/src/kernels/js/worker-shell-capture.js +1 -1
- package/src/kernels/py/prelude.py +35 -1
- package/src/kernels/py/process.ts +20 -0
- package/src/kernels/py/transport.ts +5 -1
- package/src/kernels/shared/subprocess-queue.ts +7 -1
- package/src/tool/detached-cell-manager.ts +36 -71
- package/src/tool/image-resize.ts +52 -0
- package/src/tool/image.ts +37 -72
- package/src/tool/managed-cell.ts +78 -0
- package/src/tool/render.ts +30 -5
- package/src/tool/terminal-snapshot-store.ts +35 -0
- package/src/tool/tool-result-marshal.ts +23 -0
- package/src/tool/tool-widgets.ts +16 -1
- package/src/tool/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,35 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.15-2] - 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Detached eval result cards no longer arm the 1 Hz repaint ticker (they render static with frozen elapsed time), and a live ticker whose row stops rendering now stops itself after 60 idle ticks and rearms on the next render, so transcript rebuilds and session switches cannot accumulate intervals on idle sessions ([#1696](https://github.com/code-yeongyu/senpi/issues/1696)).
|
|
26
|
+
- Bounded three unbounded retentions that grew long-lived session heaps without limit: settled eval cells now leave the live registry into a 32-entry terminal snapshot LRU, the JS kernel's unconsumed tool-call queue is capped at 256 and cleared on interrupt/reset/close/crash (mirroring the subprocess kernel), and per-cell display buffers cap at 8 images / 24 MB / 64 JSON outputs with elision notes ([#1695](https://github.com/code-yeongyu/senpi/issues/1695)).
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
|
|
30
|
+
## [2026.9.15] - 2026-09-15
|
|
31
|
+
|
|
32
|
+
### Breaking Changes
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Eval cells no longer abandon the processes they spawn. When a JavaScript cell settles, is interrupted, or times out, its `Bun.spawn`/`node:child_process` children and their descendants are terminated (SIGTERM then SIGKILL after a grace), unless the cell asked for a detached process; a child whose worker is lost while blocked is retired by the host instead. Python kernels sweep their process group when they close gracefully, and a parent-death watchdog takes the kernel and its subprocesses down when the host dies mid-cell ([#1697](https://github.com/code-yeongyu/senpi/issues/1697)).
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
15
44
|
## [2026.9.13-2] - 2026-09-13
|
|
16
45
|
|
|
17
46
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.15-2",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.15-2",
|
|
34
34
|
"typebox": "1.3.27"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.15-2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.15-2"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/bridge/reserved.ts
CHANGED
|
@@ -11,3 +11,5 @@ export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
|
|
|
11
11
|
export const TIMEOUT_RESUME_OP = "timeout-resume" as const;
|
|
12
12
|
/** Status op the JS worker emits the moment it receives `interrupt`; proves its event loop is not blocked. */
|
|
13
13
|
export const INTERRUPT_ACK_OP = "interrupt-ack" as const;
|
|
14
|
+
/** Status op the JS worker emits when a cell child is tracked or exits, so the host can retire it if the worker is lost. */
|
|
15
|
+
export const CHILD_LIFECYCLE_OP = "child" as const;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
-
import { INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
1
|
+
import type { EvalStatusEvent, HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
4
|
import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
5
5
|
import {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ToolCallMessage,
|
|
12
12
|
} from "./kernel-contract.ts";
|
|
13
13
|
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
14
|
+
import { terminateProcessTrees } from "./process-tree-host.ts";
|
|
14
15
|
import { JavaScriptRunQueue, type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts";
|
|
15
16
|
import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
|
|
16
17
|
import { WorkerSlot } from "./worker-slot.ts";
|
|
@@ -19,6 +20,13 @@ export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScript
|
|
|
19
20
|
export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
20
21
|
export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
|
|
21
22
|
|
|
23
|
+
/** How long a lost worker's children get to honour SIGTERM before the host sends SIGKILL. */
|
|
24
|
+
const WORKER_LOSS_CHILD_GRACE_MS = 1_000;
|
|
25
|
+
|
|
26
|
+
// Pull-API fallback queue bound: a nextToolCall consumer this far behind is already stalled, and the
|
|
27
|
+
// normal push path (onMessage) never reads the queue, so unbounded growth only pins tool args (#1695).
|
|
28
|
+
const MAX_PENDING_TOOL_CALLS = 256;
|
|
29
|
+
|
|
22
30
|
export class JavaScriptKernel {
|
|
23
31
|
readonly #options: JavaScriptKernelOptions;
|
|
24
32
|
readonly #moduleLoader: LocalModuleLoader;
|
|
@@ -31,6 +39,8 @@ export class JavaScriptKernel {
|
|
|
31
39
|
#timeout: NodeJS.Timeout | null = null;
|
|
32
40
|
#toolWaiters: Array<(message: ToolCallMessage) => void> = [];
|
|
33
41
|
#pendingToolCalls: ToolCallMessage[] = [];
|
|
42
|
+
/** Live cell children the worker reported; retired by the host when the worker itself is lost. */
|
|
43
|
+
readonly #childPids = new Set<number>();
|
|
34
44
|
|
|
35
45
|
constructor(options: JavaScriptKernelOptions) {
|
|
36
46
|
this.#options = options;
|
|
@@ -71,6 +81,7 @@ export class JavaScriptKernel {
|
|
|
71
81
|
async reset(): Promise<void> {
|
|
72
82
|
assertJavaScriptKernelOpen(this.#lifecycle, "reset");
|
|
73
83
|
await this.#terminate();
|
|
84
|
+
this.#clearToolCalls();
|
|
74
85
|
assertJavaScriptKernelOpen(this.#lifecycle, "reset");
|
|
75
86
|
await this.#ensureReady();
|
|
76
87
|
this.#startNext();
|
|
@@ -91,6 +102,7 @@ export class JavaScriptKernel {
|
|
|
91
102
|
this.#slot.postMessage({ type: "close" });
|
|
92
103
|
this.#lifecycle = "closing";
|
|
93
104
|
this.#runs.settleAll("JS kernel closed");
|
|
105
|
+
this.#clearToolCalls();
|
|
94
106
|
const recovery = this.#recovery;
|
|
95
107
|
const closePromise = (async () => {
|
|
96
108
|
if (recovery) await recovery;
|
|
@@ -164,15 +176,19 @@ export class JavaScriptKernel {
|
|
|
164
176
|
message: string,
|
|
165
177
|
durationMs = 0,
|
|
166
178
|
): Promise<{ readonly retained: boolean; readonly note?: string }> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
try {
|
|
180
|
+
run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
|
|
181
|
+
run.interruptAck ??= Promise.withResolvers<void>();
|
|
182
|
+
this.#slot.postMessage({ type: "interrupt", reason });
|
|
183
|
+
if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
|
|
184
|
+
if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
|
|
185
|
+
const retirement = await this.#terminate();
|
|
186
|
+
this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
|
|
187
|
+
void this.#recover(() => Promise.resolve());
|
|
188
|
+
return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
|
|
189
|
+
} finally {
|
|
190
|
+
this.#clearToolCalls();
|
|
191
|
+
}
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
async #restartAfterStop(): Promise<void> {
|
|
@@ -208,12 +224,19 @@ export class JavaScriptKernel {
|
|
|
208
224
|
this.#runs.active?.interruptAck?.resolve();
|
|
209
225
|
return;
|
|
210
226
|
}
|
|
227
|
+
if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
|
|
228
|
+
this.#trackChildEvent(message.event);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
211
231
|
this.#options.onMessage?.(message);
|
|
212
232
|
this.#runs.active?.input.onMessage?.(message);
|
|
213
233
|
if (message.type === "tool-call") {
|
|
214
234
|
const waiter = this.#toolWaiters.shift();
|
|
215
235
|
if (waiter) waiter(message);
|
|
216
|
-
else
|
|
236
|
+
else {
|
|
237
|
+
this.#pendingToolCalls.push(message);
|
|
238
|
+
if (this.#pendingToolCalls.length > MAX_PENDING_TOOL_CALLS) this.#pendingToolCalls.shift();
|
|
239
|
+
}
|
|
217
240
|
return;
|
|
218
241
|
}
|
|
219
242
|
if (message.type !== "result") return;
|
|
@@ -240,16 +263,42 @@ export class JavaScriptKernel {
|
|
|
240
263
|
durationMs: this.#runs.durationMs(active, performance.now()),
|
|
241
264
|
});
|
|
242
265
|
}
|
|
266
|
+
this.#clearToolCalls();
|
|
243
267
|
void this.#restartAfterStop();
|
|
244
268
|
}
|
|
245
269
|
|
|
270
|
+
#clearToolCalls(): void {
|
|
271
|
+
this.#pendingToolCalls.length = 0;
|
|
272
|
+
this.#toolWaiters.length = 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
246
275
|
#clearTimeout(): void {
|
|
247
276
|
if (this.#timeout) clearTimeout(this.#timeout);
|
|
248
277
|
this.#timeout = null;
|
|
249
278
|
}
|
|
250
279
|
|
|
280
|
+
#trackChildEvent(event: EvalStatusEvent): void {
|
|
281
|
+
const pid = event.pid;
|
|
282
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
|
|
283
|
+
if (event.state === "spawned") this.#childPids.add(pid);
|
|
284
|
+
else if (event.state === "exited") this.#childPids.delete(pid);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Retire the worker, then whatever cell children it still owned: a terminated or crashed worker never
|
|
289
|
+
* reaches its own cell-end cleanup, and a pid `ps` no longer lists as our child was reused and is skipped.
|
|
290
|
+
*/
|
|
251
291
|
async #terminate(): Promise<WorkerRetirement> {
|
|
252
292
|
this.#clearTimeout();
|
|
253
|
-
|
|
293
|
+
const retirement = await this.#slot.retire();
|
|
294
|
+
await this.#retireWorkerChildren();
|
|
295
|
+
return retirement;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async #retireWorkerChildren(): Promise<void> {
|
|
299
|
+
if (this.#childPids.size === 0) return;
|
|
300
|
+
const pids = [...this.#childPids];
|
|
301
|
+
this.#childPids.clear();
|
|
302
|
+
await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
|
|
254
303
|
}
|
|
255
304
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Host-side twin of worker-runtime's `process-tree.js`. The worker file must stay plain
|
|
4
|
+
// JavaScript (a Node worker thread spawned from a `.js` entry has no TypeScript loader), and
|
|
5
|
+
// `check-ts-relative-imports` forbids a `.ts` file from importing a `.js` one, so the host
|
|
6
|
+
// keeps its own copy the way `worker-core.js` mirrors `reserved.ts` constants. Keep the two in
|
|
7
|
+
// sync when either changes.
|
|
8
|
+
|
|
9
|
+
export interface TerminateProcessTreesOptions {
|
|
10
|
+
readonly graceMs: number;
|
|
11
|
+
readonly killWaitMs?: number;
|
|
12
|
+
// When set, only roots `ps` still lists as direct children of this pid are signalled, so a
|
|
13
|
+
// reused pid belonging to another process is never killed.
|
|
14
|
+
readonly ownerPid?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PS_MAX_BUFFER = 16 * 1024 * 1024;
|
|
18
|
+
const EXIT_POLL_MS = 25;
|
|
19
|
+
const ZOMBIE_CHECK_INTERVAL_MS = 250;
|
|
20
|
+
|
|
21
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
22
|
+
return typeof value === "object" && value !== null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isProcessAlive(pid: number): boolean {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return isRecord(error) && error.code === "EPERM";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
|
|
36
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, signal);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
|
|
46
|
+
function ps(args: readonly string[]): Promise<string> {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
execFile("ps", [...args], { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
|
|
49
|
+
resolve(typeof output === "string" ? output : String(output ?? "")),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readProcessTable(): Promise<Map<number, number[]>> {
|
|
55
|
+
const children = new Map<number, number[]>();
|
|
56
|
+
if (process.platform === "win32") return children;
|
|
57
|
+
const stdout = await ps(["-axo", "pid=,ppid="]);
|
|
58
|
+
for (const line of stdout.split("\n")) {
|
|
59
|
+
const [pidText, ppidText] = line.trim().split(/\s+/u);
|
|
60
|
+
const pid = Number(pidText);
|
|
61
|
+
const ppid = Number(ppidText);
|
|
62
|
+
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
|
|
63
|
+
const siblings = children.get(ppid);
|
|
64
|
+
if (siblings) siblings.push(pid);
|
|
65
|
+
else children.set(ppid, [pid]);
|
|
66
|
+
}
|
|
67
|
+
return children;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function collectDescendants(table: ReadonlyMap<number, readonly number[]>, roots: readonly number[]): number[] {
|
|
71
|
+
const seen = new Set<number>(roots);
|
|
72
|
+
const queue = [...roots];
|
|
73
|
+
const descendants: number[] = [];
|
|
74
|
+
while (queue.length > 0) {
|
|
75
|
+
const parent = queue.shift();
|
|
76
|
+
if (parent === undefined) break;
|
|
77
|
+
for (const child of table.get(parent) ?? []) {
|
|
78
|
+
if (seen.has(child)) continue;
|
|
79
|
+
seen.add(child);
|
|
80
|
+
descendants.push(child);
|
|
81
|
+
queue.push(child);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return descendants;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function ownedRoots(
|
|
88
|
+
table: ReadonlyMap<number, readonly number[]>,
|
|
89
|
+
parentPid: number,
|
|
90
|
+
roots: readonly number[],
|
|
91
|
+
): number[] {
|
|
92
|
+
const owned = new Set(table.get(parentPid) ?? []);
|
|
93
|
+
return roots.filter((pid) => owned.has(pid));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sleep(ms: number): Promise<void> {
|
|
97
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Retire whole process trees: snapshot the descendants first (a parent that dies before the
|
|
102
|
+
* snapshot would reparent them to init), SIGTERM everything, wait `graceMs` for the pids to
|
|
103
|
+
* disappear, then SIGKILL whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
|
|
104
|
+
*/
|
|
105
|
+
export async function terminateProcessTrees(
|
|
106
|
+
roots: readonly number[],
|
|
107
|
+
options: TerminateProcessTreesOptions,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const liveRoots = roots.filter(isProcessAlive);
|
|
110
|
+
if (liveRoots.length === 0) return;
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
for (const pid of liveRoots) await taskkillTree(pid);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const table = await readProcessTable();
|
|
116
|
+
const owned = options.ownerPid === undefined ? liveRoots : ownedRoots(table, options.ownerPid, liveRoots);
|
|
117
|
+
if (owned.length === 0) return;
|
|
118
|
+
const targets = [...owned, ...collectDescendants(table, owned)];
|
|
119
|
+
for (const pid of targets) signalProcess(pid, "SIGTERM");
|
|
120
|
+
const survivors = await waitForExit(targets, options.graceMs);
|
|
121
|
+
if (survivors.length === 0) return;
|
|
122
|
+
for (const pid of survivors) signalProcess(pid, "SIGKILL");
|
|
123
|
+
await waitForExit(survivors, options.killWaitMs ?? options.graceMs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is blocked or
|
|
127
|
+
// gone is never reaped, so liveness also consults `ps` state.
|
|
128
|
+
async function waitForExit(pids: readonly number[], graceMs: number): Promise<number[]> {
|
|
129
|
+
const deadline = Date.now() + graceMs;
|
|
130
|
+
let remaining = await withoutZombies(pids.filter(isProcessAlive));
|
|
131
|
+
let nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
|
|
132
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
133
|
+
await sleep(EXIT_POLL_MS);
|
|
134
|
+
remaining = remaining.filter(isProcessAlive);
|
|
135
|
+
if (remaining.length > 0 && Date.now() >= nextZombieCheck) {
|
|
136
|
+
remaining = await withoutZombies(remaining);
|
|
137
|
+
nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return remaining;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function withoutZombies(pids: number[]): Promise<number[]> {
|
|
144
|
+
if (pids.length === 0 || process.platform === "win32") return pids;
|
|
145
|
+
const stdout = await ps(["-o", "pid=,stat=", "-p", pids.join(",")]);
|
|
146
|
+
const zombies = new Set<number>();
|
|
147
|
+
for (const line of stdout.split("\n")) {
|
|
148
|
+
const [pidText, stat] = line.trim().split(/\s+/u);
|
|
149
|
+
if (typeof stat === "string" && stat.startsWith("Z")) zombies.add(Number(pidText));
|
|
150
|
+
}
|
|
151
|
+
return pids.filter((pid) => !zombies.has(pid));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function taskkillTree(pid: number): Promise<void> {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ProcessTable = ReadonlyMap<number, readonly number[]>;
|
|
2
|
+
|
|
3
|
+
export interface TerminateProcessTreesOptions {
|
|
4
|
+
/** How long the trees get to honour SIGTERM before SIGKILL. */
|
|
5
|
+
readonly graceMs: number;
|
|
6
|
+
/** How long to wait after SIGKILL; defaults to `graceMs`. */
|
|
7
|
+
readonly killWaitMs?: number;
|
|
8
|
+
/** Resolves once every owned handle has exited; shortens the wait when it settles early. */
|
|
9
|
+
readonly settled?: Promise<unknown>;
|
|
10
|
+
/** When set, only roots `ps` still lists as direct children of this pid are signalled (guards against pid reuse). */
|
|
11
|
+
readonly ownerPid?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isProcessAlive(pid: number): boolean;
|
|
15
|
+
|
|
16
|
+
export function signalProcess(pid: number, signal: NodeJS.Signals): boolean;
|
|
17
|
+
|
|
18
|
+
export function readProcessTable(): Promise<ProcessTable>;
|
|
19
|
+
|
|
20
|
+
export function collectDescendants(table: ProcessTable, roots: readonly number[]): number[];
|
|
21
|
+
|
|
22
|
+
export function ownedRoots(table: ProcessTable, parentPid: number, roots: readonly number[]): number[];
|
|
23
|
+
|
|
24
|
+
export function terminateProcessTrees(roots: readonly number[], options: TerminateProcessTreesOptions): Promise<void>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Plain JS (with process-tree.d.ts) because the worker runtime imports it and
|
|
4
|
+
// worker files cannot import TypeScript; the host imports the same module so a
|
|
5
|
+
// process tree is retired by one procedure wherever its owner is lost.
|
|
6
|
+
|
|
7
|
+
const PS_MAX_BUFFER = 16 * 1024 * 1024;
|
|
8
|
+
const EXIT_POLL_MS = 25;
|
|
9
|
+
const ZOMBIE_CHECK_INTERVAL_MS = 250;
|
|
10
|
+
|
|
11
|
+
export function isProcessAlive(pid) {
|
|
12
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return isRecord(error) && error.code === "EPERM";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function signalProcess(pid, signal) {
|
|
22
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, signal);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parent-to-children map of every process visible to `ps`. Windows has no
|
|
33
|
+
* `ps`; callers there kill through `taskkill /T`, which walks the tree itself.
|
|
34
|
+
*/
|
|
35
|
+
export async function readProcessTable() {
|
|
36
|
+
if (process.platform === "win32") return new Map();
|
|
37
|
+
const stdout = await ps(["-axo", "pid=,ppid="]);
|
|
38
|
+
const children = new Map();
|
|
39
|
+
for (const line of stdout.split("\n")) {
|
|
40
|
+
const [pidText, ppidText] = line.trim().split(/\s+/u);
|
|
41
|
+
const pid = Number(pidText);
|
|
42
|
+
const ppid = Number(ppidText);
|
|
43
|
+
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
|
|
44
|
+
const siblings = children.get(ppid);
|
|
45
|
+
if (siblings) siblings.push(pid);
|
|
46
|
+
else children.set(ppid, [pid]);
|
|
47
|
+
}
|
|
48
|
+
return children;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectDescendants(table, roots) {
|
|
52
|
+
const seen = new Set(roots);
|
|
53
|
+
const queue = [...roots];
|
|
54
|
+
const descendants = [];
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const parent = queue.shift();
|
|
57
|
+
for (const child of table.get(parent) ?? []) {
|
|
58
|
+
if (seen.has(child)) continue;
|
|
59
|
+
seen.add(child);
|
|
60
|
+
descendants.push(child);
|
|
61
|
+
queue.push(child);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return descendants;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Roots that `ps` still reports as direct children of `parentPid`; a reused pid belongs to someone else. */
|
|
68
|
+
export function ownedRoots(table, parentPid, roots) {
|
|
69
|
+
const owned = new Set(table.get(parentPid) ?? []);
|
|
70
|
+
return roots.filter((pid) => owned.has(pid));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sleep(ms) {
|
|
74
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Retire whole process trees: snapshot the descendants first (a parent that
|
|
79
|
+
* dies before the snapshot would reparent them to init), SIGTERM everything,
|
|
80
|
+
* wait `graceMs` for `settled` (or for the pids to disappear), then SIGKILL
|
|
81
|
+
* whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
|
|
82
|
+
*/
|
|
83
|
+
export async function terminateProcessTrees(roots, options) {
|
|
84
|
+
const liveRoots = roots.filter(isProcessAlive);
|
|
85
|
+
if (liveRoots.length === 0) return;
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
for (const pid of liveRoots) await taskkillTree(pid);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const table = await readProcessTable();
|
|
91
|
+
const owned = options.ownerPid === undefined ? liveRoots : ownedRoots(table, options.ownerPid, liveRoots);
|
|
92
|
+
if (owned.length === 0) return;
|
|
93
|
+
const targets = [...owned, ...collectDescendants(table, owned)];
|
|
94
|
+
for (const pid of targets) signalProcess(pid, "SIGTERM");
|
|
95
|
+
const survivors = await waitForExit(targets, options.graceMs, options.settled);
|
|
96
|
+
if (survivors.length === 0) return;
|
|
97
|
+
for (const pid of survivors) signalProcess(pid, "SIGKILL");
|
|
98
|
+
await waitForExit(survivors, options.killWaitMs ?? options.graceMs, options.settled);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is
|
|
102
|
+
// blocked or gone is never reaped, so liveness also consults `ps` state.
|
|
103
|
+
async function waitForExit(pids, graceMs, settled) {
|
|
104
|
+
const deadline = Date.now() + graceMs;
|
|
105
|
+
let remaining = await withoutZombies(pids.filter(isProcessAlive));
|
|
106
|
+
let nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
|
|
107
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
108
|
+
if (settled) {
|
|
109
|
+
const outcome = await Promise.race([settled.then(() => "settled"), sleep(EXIT_POLL_MS).then(() => "tick")]);
|
|
110
|
+
if (outcome === "settled") settled = undefined;
|
|
111
|
+
} else {
|
|
112
|
+
await sleep(EXIT_POLL_MS);
|
|
113
|
+
}
|
|
114
|
+
remaining = remaining.filter(isProcessAlive);
|
|
115
|
+
if (remaining.length > 0 && Date.now() >= nextZombieCheck) {
|
|
116
|
+
remaining = await withoutZombies(remaining);
|
|
117
|
+
nextZombieCheck = Date.now() + ZOMBIE_CHECK_INTERVAL_MS;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return remaining;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function withoutZombies(pids) {
|
|
124
|
+
if (pids.length === 0 || process.platform === "win32") return pids;
|
|
125
|
+
const stdout = await ps(["-o", "pid=,stat=", "-p", pids.join(",")]);
|
|
126
|
+
const zombies = new Set();
|
|
127
|
+
for (const line of stdout.split("\n")) {
|
|
128
|
+
const [pidText, stat] = line.trim().split(/\s+/u);
|
|
129
|
+
if (typeof stat === "string" && stat.startsWith("Z")) zombies.add(Number(pidText));
|
|
130
|
+
}
|
|
131
|
+
return pids.filter((pid) => !zombies.has(pid));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
|
|
135
|
+
function ps(args) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
|
|
138
|
+
resolve(typeof output === "string" ? output : String(output ?? "")),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function taskkillTree(pid) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isRecord(value) {
|
|
150
|
+
return typeof value === "object" && value !== null;
|
|
151
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
2
2
|
|
|
3
|
-
// Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
|
|
3
|
+
// Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
|
|
4
4
|
const INTERRUPT_ACK_OP = "interrupt-ack";
|
|
5
|
+
const CHILD_LIFECYCLE_OP = "child";
|
|
5
6
|
|
|
6
7
|
// Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
|
|
7
8
|
// cannot import TypeScript). Keys the active session does not set must be dropped so a
|
|
@@ -73,6 +74,7 @@ export function createWorkerCore(transport, options) {
|
|
|
73
74
|
parallelPoolWidth: options.parallelPoolWidth,
|
|
74
75
|
localRoots: message.connection.localRoots,
|
|
75
76
|
artifactsDir: message.connection.artifactsDir,
|
|
77
|
+
onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
|
|
76
78
|
});
|
|
77
79
|
emit({ type: "ready" });
|
|
78
80
|
return;
|
|
@@ -3,10 +3,14 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
4
4
|
import { inspect } from "node:util";
|
|
5
5
|
import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
|
|
6
|
+
import { terminateProcessTrees } from "./process-tree.js";
|
|
6
7
|
import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
|
|
7
8
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
8
9
|
|
|
9
10
|
const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
|
|
11
|
+
// How long a child gets to honour SIGTERM before SIGKILL. Short, because the
|
|
12
|
+
// cell has already produced its value and the caller is waiting on settle.
|
|
13
|
+
const CHILD_TERMINATION_GRACE_MS = 1_000;
|
|
10
14
|
const INTERNAL_URL = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu;
|
|
11
15
|
|
|
12
16
|
export class JsWorkerRuntime {
|
|
@@ -17,10 +21,12 @@ export class JsWorkerRuntime {
|
|
|
17
21
|
#hooks = null;
|
|
18
22
|
#pendingDisplays = [];
|
|
19
23
|
#children = new Set();
|
|
24
|
+
#onChildEvent;
|
|
20
25
|
|
|
21
26
|
constructor(options) {
|
|
22
27
|
this.#cwd = options.cwd;
|
|
23
28
|
this.#parallelPoolWidth = options.parallelPoolWidth;
|
|
29
|
+
this.#onChildEvent = typeof options.onChildEvent === "function" ? options.onChildEvent : null;
|
|
24
30
|
this.#localRoots = { ...(options.localRoots ?? {}) };
|
|
25
31
|
if (options.artifactsDir && !this.#localRoots.local) this.#localRoots.local = join(options.artifactsDir, "local");
|
|
26
32
|
this.#installGlobals();
|
|
@@ -42,24 +48,46 @@ export class JsWorkerRuntime {
|
|
|
42
48
|
return value;
|
|
43
49
|
} finally {
|
|
44
50
|
this.#pendingDisplays = [];
|
|
45
|
-
|
|
51
|
+
// A child still running here has lost its only owner: the cell that
|
|
52
|
+
// spawned it is over, nothing will await it again, and it would be
|
|
53
|
+
// reparented to init. Retire it the way timeout and abort cleanup
|
|
54
|
+
// already do, unless the cell asked for a detached process.
|
|
55
|
+
await this.#terminateChildren();
|
|
46
56
|
this.#hooks = null;
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
interrupt() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
// The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
|
|
62
|
+
// the caller's interrupt latency stays that of the acknowledgement.
|
|
63
|
+
void this.#terminateChildren();
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
#trackChild(child) {
|
|
66
|
+
#trackChild(child, spawnOptions) {
|
|
57
67
|
if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
|
|
68
|
+
// `detached: true` is the cell saying it wants the process to outlive it.
|
|
69
|
+
if (isPlainObject(spawnOptions) && spawnOptions.detached === true) return;
|
|
58
70
|
this.#children.add(child);
|
|
59
|
-
const
|
|
71
|
+
const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
|
|
72
|
+
// The host keeps its own copy of live pids: if this worker is terminated
|
|
73
|
+
// while blocked, only the host can still retire them.
|
|
74
|
+
if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
|
|
75
|
+
const forget = () => {
|
|
76
|
+
this.#children.delete(child);
|
|
77
|
+
if (pid !== null) this.#onChildEvent?.({ pid, state: "exited" });
|
|
78
|
+
};
|
|
60
79
|
if (child.exited instanceof Promise) child.exited.then(forget, forget);
|
|
61
80
|
}
|
|
62
81
|
|
|
82
|
+
#terminateChildren() {
|
|
83
|
+
const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
|
|
84
|
+
this.#children.clear();
|
|
85
|
+
if (children.length === 0) return undefined;
|
|
86
|
+
const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
|
|
87
|
+
const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
|
|
88
|
+
return terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled });
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
async #drainPendingDisplays() {
|
|
64
92
|
while (this.#pendingDisplays.length > 0) {
|
|
65
93
|
const pending = this.#pendingDisplays;
|
|
@@ -114,7 +142,7 @@ export class JsWorkerRuntime {
|
|
|
114
142
|
const restoreShellCapture = installShellCapture({
|
|
115
143
|
isActive: () => this.#hooks !== null,
|
|
116
144
|
emitText: (stream, data) => this.#emitText(stream, data),
|
|
117
|
-
onChild: (child) => this.#trackChild(child),
|
|
145
|
+
onChild: (child, spawnOptions) => this.#trackChild(child, spawnOptions),
|
|
118
146
|
});
|
|
119
147
|
globalThis.__senpi_restore_console__ = () => {
|
|
120
148
|
console.log = originalLog;
|
|
@@ -3,6 +3,7 @@ export type ShellCaptureStream = "stdout" | "stderr";
|
|
|
3
3
|
export type ShellCaptureRestore = () => void;
|
|
4
4
|
|
|
5
5
|
export interface ShellCaptureChild {
|
|
6
|
+
readonly pid?: number;
|
|
6
7
|
readonly exitCode: number | null;
|
|
7
8
|
readonly signalCode: string | null;
|
|
8
9
|
readonly exited: Promise<number>;
|
|
@@ -13,7 +14,7 @@ export interface ShellCaptureOptions {
|
|
|
13
14
|
readonly isActive: () => boolean;
|
|
14
15
|
readonly emitText: (stream: ShellCaptureStream, data: string) => void;
|
|
15
16
|
/** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
|
|
16
|
-
readonly onChild?: (child: ShellCaptureChild) => void;
|
|
17
|
+
readonly onChild?: (child: ShellCaptureChild, spawnOptions?: unknown) => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
|