@code-yeongyu/senpi-codemode 2026.9.13-2 → 2026.9.15
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 +14 -0
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +33 -3
- 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/tool/tool-widgets.ts +16 -1
- package/src/tool/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.15] - 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 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)).
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
15
29
|
## [2026.9.13-2] - 2026-09-13
|
|
16
30
|
|
|
17
31
|
### 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",
|
|
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",
|
|
34
34
|
"typebox": "1.3.27"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.15"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.15"
|
|
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,9 @@ 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
|
+
|
|
22
26
|
export class JavaScriptKernel {
|
|
23
27
|
readonly #options: JavaScriptKernelOptions;
|
|
24
28
|
readonly #moduleLoader: LocalModuleLoader;
|
|
@@ -31,6 +35,8 @@ export class JavaScriptKernel {
|
|
|
31
35
|
#timeout: NodeJS.Timeout | null = null;
|
|
32
36
|
#toolWaiters: Array<(message: ToolCallMessage) => void> = [];
|
|
33
37
|
#pendingToolCalls: ToolCallMessage[] = [];
|
|
38
|
+
/** Live cell children the worker reported; retired by the host when the worker itself is lost. */
|
|
39
|
+
readonly #childPids = new Set<number>();
|
|
34
40
|
|
|
35
41
|
constructor(options: JavaScriptKernelOptions) {
|
|
36
42
|
this.#options = options;
|
|
@@ -208,6 +214,10 @@ export class JavaScriptKernel {
|
|
|
208
214
|
this.#runs.active?.interruptAck?.resolve();
|
|
209
215
|
return;
|
|
210
216
|
}
|
|
217
|
+
if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
|
|
218
|
+
this.#trackChildEvent(message.event);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
211
221
|
this.#options.onMessage?.(message);
|
|
212
222
|
this.#runs.active?.input.onMessage?.(message);
|
|
213
223
|
if (message.type === "tool-call") {
|
|
@@ -248,8 +258,28 @@ export class JavaScriptKernel {
|
|
|
248
258
|
this.#timeout = null;
|
|
249
259
|
}
|
|
250
260
|
|
|
261
|
+
#trackChildEvent(event: EvalStatusEvent): void {
|
|
262
|
+
const pid = event.pid;
|
|
263
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
|
|
264
|
+
if (event.state === "spawned") this.#childPids.add(pid);
|
|
265
|
+
else if (event.state === "exited") this.#childPids.delete(pid);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Retire the worker, then whatever cell children it still owned: a terminated or crashed worker never
|
|
270
|
+
* reaches its own cell-end cleanup, and a pid `ps` no longer lists as our child was reused and is skipped.
|
|
271
|
+
*/
|
|
251
272
|
async #terminate(): Promise<WorkerRetirement> {
|
|
252
273
|
this.#clearTimeout();
|
|
253
|
-
|
|
274
|
+
const retirement = await this.#slot.retire();
|
|
275
|
+
await this.#retireWorkerChildren();
|
|
276
|
+
return retirement;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async #retireWorkerChildren(): Promise<void> {
|
|
280
|
+
if (this.#childPids.size === 0) return;
|
|
281
|
+
const pids = [...this.#childPids];
|
|
282
|
+
this.#childPids.clear();
|
|
283
|
+
await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
|
|
254
284
|
}
|
|
255
285
|
}
|
|
@@ -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;
|
|
@@ -154,7 +154,7 @@ function capturedSpawn(originalSpawn, options, pinEnv) {
|
|
|
154
154
|
? originalSpawn(...args)
|
|
155
155
|
: originalSpawn(effective);
|
|
156
156
|
}
|
|
157
|
-
options.onChild?.(child);
|
|
157
|
+
options.onChild?.(child, Array.isArray(first) ? second : first);
|
|
158
158
|
return child;
|
|
159
159
|
};
|
|
160
160
|
}
|
|
@@ -23,7 +23,7 @@ import uuid
|
|
|
23
23
|
from collections.abc import Iterable
|
|
24
24
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from threading import Lock
|
|
26
|
+
from threading import Lock, Thread
|
|
27
27
|
from typing import Any, Callable
|
|
28
28
|
from urllib.parse import unquote
|
|
29
29
|
|
|
@@ -1022,14 +1022,48 @@ def handle(message: dict[str, Any]) -> bool:
|
|
|
1022
1022
|
return True
|
|
1023
1023
|
|
|
1024
1024
|
|
|
1025
|
+
def _terminate_process_group() -> None:
|
|
1026
|
+
# The kernel is spawned into its own session (setsid), so its pid is its process
|
|
1027
|
+
# group id and a cell's subprocesses inherit that group. Killing the group takes
|
|
1028
|
+
# those children down with the kernel instead of orphaning them to init.
|
|
1029
|
+
if os.name != "posix":
|
|
1030
|
+
return
|
|
1031
|
+
with contextlib.suppress(OSError):
|
|
1032
|
+
os.killpg(os.getpgrp(), signal.SIGKILL)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _watch_parent(initial_ppid: int) -> None:
|
|
1036
|
+
# A cell blocked in the main thread (for example a multiprocessing pool) never
|
|
1037
|
+
# returns to the stdin loop, so it cannot notice the host closing its pipe. This
|
|
1038
|
+
# daemon thread notices the reparenting instead and takes the whole group down.
|
|
1039
|
+
while True:
|
|
1040
|
+
time.sleep(1.0)
|
|
1041
|
+
if os.getppid() != initial_ppid:
|
|
1042
|
+
_terminate_process_group()
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _start_parent_watch() -> None:
|
|
1047
|
+
if os.name != "posix":
|
|
1048
|
+
return
|
|
1049
|
+
Thread(target=_watch_parent, args=(os.getppid(),), name="senpi-parent-watch", daemon=True).start()
|
|
1050
|
+
|
|
1051
|
+
|
|
1025
1052
|
def main() -> None:
|
|
1026
1053
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1054
|
+
_start_parent_watch()
|
|
1055
|
+
host_closed = False
|
|
1027
1056
|
for raw in sys.stdin:
|
|
1028
1057
|
try:
|
|
1029
1058
|
if not handle(json.loads(raw)):
|
|
1059
|
+
host_closed = True
|
|
1030
1060
|
break
|
|
1031
1061
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|
|
1032
1062
|
emit({"type": "init-failed", "error": bridge_error(exc)})
|
|
1063
|
+
# Reaching here without a close frame means the host's pipe hit EOF: it is gone,
|
|
1064
|
+
# so retire any subprocess the last cell left running before the interpreter exits.
|
|
1065
|
+
if not host_closed:
|
|
1066
|
+
_terminate_process_group()
|
|
1033
1067
|
|
|
1034
1068
|
|
|
1035
1069
|
if __name__ == "__main__":
|
|
@@ -45,6 +45,12 @@ export function splitCommand(commandLine: string): { readonly command: string; r
|
|
|
45
45
|
return { command, args };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function errorCode(error: unknown): string | undefined {
|
|
49
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
50
|
+
const code = Reflect.get(error, "code");
|
|
51
|
+
return typeof code === "string" ? code : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
export function numberOrNull(value: unknown): number | null {
|
|
49
55
|
return typeof value === "number" ? value : null;
|
|
50
56
|
}
|
|
@@ -80,6 +86,20 @@ export async function waitForExit(child: KernelChild, timeoutMs: number): Promis
|
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
// A cell's own subprocess is spawned into the detached kernel's process group. When the
|
|
90
|
+
// leader exits gracefully on close, kill the group so a Popen the cell left running does
|
|
91
|
+
// not outlive the kernel; the group is addressed by the leader pid (== pgid) while members live.
|
|
92
|
+
export function sweepProcessGroup(child: KernelChild): void {
|
|
93
|
+
if (child.pid === undefined || process.platform === "win32") return;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(-child.pid, "SIGKILL");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// ESRCH means the group is already gone (the success case). Any other error
|
|
98
|
+
// (for example EPERM on a member we cannot signal) must not fail a graceful close.
|
|
99
|
+
if (errorCode(error) !== "ESRCH") return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
|
|
84
104
|
await new Promise<void>((resolve, reject) => {
|
|
85
105
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
numberOrNull,
|
|
20
20
|
signalOrNull,
|
|
21
21
|
splitCommand,
|
|
22
|
+
sweepProcessGroup,
|
|
22
23
|
waitForExit,
|
|
23
24
|
withTimeout,
|
|
24
25
|
} from "./process.ts";
|
|
@@ -145,7 +146,10 @@ export class PythonKernelTransport {
|
|
|
145
146
|
} catch (error) {
|
|
146
147
|
if (!(error instanceof Error)) throw error;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// hardKill kills the whole group; a graceful leader exit does not, so sweep it
|
|
150
|
+
// to retire any subprocess the cell left running in the kernel's process group.
|
|
151
|
+
if (await exited) sweepProcessGroup(this.#child);
|
|
152
|
+
else await hardKill(this.#child, hardKillWaitMs);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
retire(): Promise<void> {
|
package/src/tool/tool-widgets.ts
CHANGED
|
@@ -139,6 +139,15 @@ function renderWith<TParams extends TSchema, TDetails, TState>(
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function grepDetailsSummary(summary: EvalToolCallSummary): string | undefined {
|
|
143
|
+
const details = summary.details;
|
|
144
|
+
if (details === undefined || details === null || typeof details !== "object") return undefined;
|
|
145
|
+
const record = details as { matchCount?: unknown; fileCount?: unknown; engine?: unknown };
|
|
146
|
+
if (typeof record.fileCount !== "number" || typeof record.engine !== "string") return undefined;
|
|
147
|
+
if (record.matchCount !== null && typeof record.matchCount !== "number") return undefined;
|
|
148
|
+
return `matches=${record.matchCount ?? "n/a"} files=${record.fileCount} (${record.engine})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
function createCoreWidgets(cwd: string): CoreWidgets {
|
|
143
152
|
const bashDefinition = createBashToolDefinition(cwd);
|
|
144
153
|
const readDefinition = createReadToolDefinition(cwd);
|
|
@@ -155,7 +164,13 @@ function createCoreWidgets(cwd: string): CoreWidgets {
|
|
|
155
164
|
}),
|
|
156
165
|
read: (summary, options) => renderWith(readDefinition, summary, options, {}),
|
|
157
166
|
write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
|
|
158
|
-
grep: (summary, options) =>
|
|
167
|
+
grep: (summary, options) => {
|
|
168
|
+
const lines = renderWith(grepDefinition, summary, options, {});
|
|
169
|
+
if (lines === undefined) return undefined;
|
|
170
|
+
const extra = grepDetailsSummary(summary);
|
|
171
|
+
if (extra !== undefined) lines.push(` ${style(options.theme, "muted", extra)}`);
|
|
172
|
+
return lines;
|
|
173
|
+
},
|
|
159
174
|
find: (summary, options) => renderWith(findDefinition, summary, options, {}),
|
|
160
175
|
ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
|
|
161
176
|
};
|
package/src/tool/types.ts
CHANGED
|
@@ -153,6 +153,7 @@ export interface EvalToolCallSummary {
|
|
|
153
153
|
readonly argsTruncated?: boolean;
|
|
154
154
|
readonly durationMs?: number;
|
|
155
155
|
readonly resultPreview?: string;
|
|
156
|
+
readonly details?: unknown;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;
|