@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-11
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 +190 -0
- package/README.md +161 -11
- package/package.json +6 -5
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +9 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +52 -0
- package/src/bridges/eval-virtual-schemas.ts +11 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/editable-check.ts +1 -1
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +5 -1
- package/src/environments/python-environments.ts +1 -1
- package/src/environments/revision-store.ts +20 -4
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +51 -13
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +20 -6
- package/src/index.ts +3 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/jl/runner.jl +11 -22
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +46 -51
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +25 -6
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-tree-host.ts +4 -8
- package/src/kernels/js/process-tree.d.ts +11 -0
- package/src/kernels/js/process-tree.js +55 -10
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/windows-tree-kill-host.ts +117 -0
- package/src/kernels/js/windows-tree-kill.d.ts +19 -0
- package/src/kernels/js/windows-tree-kill.js +100 -0
- package/src/kernels/js/worker-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +46 -12
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +66 -13
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +9 -0
- package/src/kernels/js/worker-slot.ts +8 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/prelude.py +69 -27
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/sandbox-cell.ts +141 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/kernels/shared/kernel-memory-host.ts +30 -7
- package/src/kernels/shared/process-cpu.ts +7 -3
- package/src/kernels/shared/process-group-cpu.ts +6 -3
- package/src/kernels/shared/subprocess-kernel.ts +29 -3
- package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
- package/src/kernels/shared/subprocess-process.ts +24 -2
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +12 -11
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +4 -8
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +19 -5
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +18 -3
- package/src/tool/magic-cell-host.ts +60 -2
- package/src/tool/magic-cells.ts +12 -2
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render-agent.ts +110 -0
- package/src/tool/render-blocks.ts +252 -0
- package/src/tool/render-call.ts +130 -0
- package/src/tool/render-cell.ts +165 -0
- package/src/tool/render-detail.ts +152 -0
- package/src/tool/render-live.ts +288 -0
- package/src/tool/render-result.ts +202 -0
- package/src/tool/render-status.ts +166 -0
- package/src/tool/render-tail.ts +139 -0
- package/src/tool/render.ts +3 -1069
- package/src/tool/run-eval-cell.ts +53 -16
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +34 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { killWindowsTree, listWindowsRows } from "./windows-tree-kill.js";
|
|
2
3
|
|
|
3
4
|
// Plain JS (with process-tree.d.ts) because the worker runtime imports it and
|
|
4
5
|
// worker files cannot import TypeScript; the host imports the same module so a
|
|
@@ -6,6 +7,8 @@ import { execFile } from "node:child_process";
|
|
|
6
7
|
|
|
7
8
|
const PS_MAX_BUFFER = 16 * 1024 * 1024;
|
|
8
9
|
const EXIT_POLL_MS = 25;
|
|
10
|
+
// A hung `ps` must not hold a cell's retirement; empty output then means nothing is signalled.
|
|
11
|
+
const PS_LOOKUP_TIMEOUT_MS = 2_000;
|
|
9
12
|
const ZOMBIE_CHECK_INTERVAL_MS = 250;
|
|
10
13
|
|
|
11
14
|
export function isProcessAlive(pid) {
|
|
@@ -30,7 +33,7 @@ export function signalProcess(pid, signal) {
|
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* Parent-to-children map of every process visible to `ps`. Windows has no
|
|
33
|
-
* `ps`; callers there kill
|
|
36
|
+
* `ps`; callers there kill the checked tree from `windows-tree-kill.js`.
|
|
34
37
|
*/
|
|
35
38
|
export async function readProcessTable() {
|
|
36
39
|
if (process.platform === "win32") return new Map();
|
|
@@ -78,13 +81,14 @@ function sleep(ms) {
|
|
|
78
81
|
* Retire whole process trees: snapshot the descendants first (a parent that
|
|
79
82
|
* dies before the snapshot would reparent them to init), SIGTERM everything,
|
|
80
83
|
* wait `graceMs` for `settled` (or for the pids to disappear), then SIGKILL
|
|
81
|
-
* whatever is left. On Windows
|
|
84
|
+
* whatever is left. On Windows the checked tree is killed by pid (`windows-tree-kill.js`).
|
|
82
85
|
*/
|
|
83
86
|
export async function terminateProcessTrees(roots, options) {
|
|
84
87
|
const liveRoots = roots.filter(isProcessAlive);
|
|
85
88
|
if (liveRoots.length === 0) return;
|
|
86
89
|
if (process.platform === "win32") {
|
|
87
|
-
|
|
90
|
+
const listing = listWindowsRows();
|
|
91
|
+
for (const pid of liveRoots) await killWindowsTree(pid, listing);
|
|
88
92
|
return;
|
|
89
93
|
}
|
|
90
94
|
const table = await readProcessTable();
|
|
@@ -98,6 +102,52 @@ export async function terminateProcessTrees(roots, options) {
|
|
|
98
102
|
await waitForExit(survivors, options.killWaitMs ?? options.graceMs, options.settled);
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Retire whole process groups the worker created (POSIX): SIGTERM each group that still has members, wait `graceMs`,
|
|
107
|
+
* then SIGKILL what is left. A group whose leader already exited is skipped while a live process holds the leader's
|
|
108
|
+
* pid, since that pid was reused. The agent's own group is never signalled.
|
|
109
|
+
*/
|
|
110
|
+
export async function terminateProcessGroups(groups, options) {
|
|
111
|
+
if (process.platform === "win32") return;
|
|
112
|
+
const ours = (group) => groupHasMembers(group.pgid) && !(group.leaderExited && isProcessAlive(group.pgid));
|
|
113
|
+
const candidates = groups.filter((group) => Number.isInteger(group.pgid) && group.pgid > 1 && ours(group));
|
|
114
|
+
if (candidates.length === 0) return;
|
|
115
|
+
// Without the agent's own group there is no way to rule it out, so nothing is signalled.
|
|
116
|
+
const ownGroup = await processGroupOf(process.pid);
|
|
117
|
+
if (ownGroup === undefined) return;
|
|
118
|
+
const live = candidates.filter((group) => group.pgid !== ownGroup && ours(group));
|
|
119
|
+
for (const group of live) signalGroup(group.pgid, "SIGTERM");
|
|
120
|
+
const deadline = Date.now() + options.graceMs;
|
|
121
|
+
let remaining = live;
|
|
122
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
123
|
+
await sleep(EXIT_POLL_MS);
|
|
124
|
+
remaining = remaining.filter(ours);
|
|
125
|
+
}
|
|
126
|
+
for (const group of remaining) signalGroup(group.pgid, "SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function groupHasMembers(pgid) {
|
|
130
|
+
try {
|
|
131
|
+
process.kill(-pgid, 0);
|
|
132
|
+
return true;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return isRecord(error) && error.code === "EPERM";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function signalGroup(pgid, signal) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(-pgid, signal);
|
|
141
|
+
} catch {
|
|
142
|
+
// The group emptied between the check and the signal.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function processGroupOf(pid) {
|
|
147
|
+
const text = (await ps(["-o", "pgid=", "-p", String(pid)], { timeout: PS_LOOKUP_TIMEOUT_MS })).trim();
|
|
148
|
+
return /^\d+$/u.test(text) ? Number(text) : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
101
151
|
// `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is
|
|
102
152
|
// blocked or gone is never reaped, so liveness also consults `ps` state.
|
|
103
153
|
async function waitForExit(pids, graceMs, settled) {
|
|
@@ -132,19 +182,14 @@ async function withoutZombies(pids) {
|
|
|
132
182
|
}
|
|
133
183
|
|
|
134
184
|
// `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
|
|
135
|
-
function ps(args) {
|
|
185
|
+
function ps(args, options = {}) {
|
|
136
186
|
return new Promise((resolve) => {
|
|
137
|
-
execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
|
|
187
|
+
execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true, ...options }, (_error, output) =>
|
|
138
188
|
resolve(typeof output === "string" ? output : String(output ?? "")),
|
|
139
189
|
);
|
|
140
190
|
});
|
|
141
191
|
}
|
|
142
192
|
|
|
143
|
-
function taskkillTree(pid) {
|
|
144
|
-
return new Promise((resolve) => {
|
|
145
|
-
execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
193
|
|
|
149
194
|
function isRecord(value) {
|
|
150
195
|
return typeof value === "object" && value !== null;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
7
|
+
import { isKernelToHostMessage, parseBridgeJsonLine, validateBridgeMessage } from "../../bridge/protocol.ts";
|
|
8
|
+
import type { EvalRuntimeInfo } from "../../tool/types.ts";
|
|
9
|
+
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
10
|
+
import { type SubprocessLike, SubprocessProcess, spawnSubprocess } from "../shared/subprocess-process.ts";
|
|
11
|
+
import type { WorkerLike } from "./inline-worker.ts";
|
|
12
|
+
import { JavaScriptWorkerExitedError } from "./worker-host.ts";
|
|
13
|
+
|
|
14
|
+
const PROCESS_CLOSE_GRACE_MS = 2_000;
|
|
15
|
+
|
|
16
|
+
export interface JavaScriptProcessEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
17
|
+
readonly localPath?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveJsProcessEntryUrl(options: JavaScriptProcessEntryUrlOptions = {}): URL {
|
|
21
|
+
const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "process-entry.js");
|
|
22
|
+
return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "process-entry.js"), options));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class JavaScriptProcessRuntimeUnavailableError extends Error {
|
|
26
|
+
readonly name = "JavaScriptProcessRuntimeUnavailableError";
|
|
27
|
+
readonly runtime: string;
|
|
28
|
+
readonly searchPath: string;
|
|
29
|
+
|
|
30
|
+
constructor(runtime: string, searchPath: string) {
|
|
31
|
+
super(
|
|
32
|
+
`JavaScript runtime is unavailable: no ${runtime} executable found on PATH${searchPath.length === 0 ? " (empty)" : ""}. Install bun or node, or use isolation.js: "worker".`,
|
|
33
|
+
);
|
|
34
|
+
this.runtime = runtime;
|
|
35
|
+
this.searchPath = searchPath;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface JavaScriptProcessWorkerOptions {
|
|
40
|
+
readonly cwd: string;
|
|
41
|
+
readonly parallelPoolWidth: number;
|
|
42
|
+
readonly searchPath?: string;
|
|
43
|
+
readonly execPath?: string;
|
|
44
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
45
|
+
readonly spawn?: (
|
|
46
|
+
command: string,
|
|
47
|
+
args: readonly string[],
|
|
48
|
+
options: { cwd?: string; env?: NodeJS.ProcessEnv },
|
|
49
|
+
) => SubprocessLike;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JavaScriptProcessWorker extends WorkerLike {
|
|
53
|
+
readonly pid?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The runtime that runs the kernel child: the host's own runtime (bun when senpi runs on Bun, node otherwise), taken
|
|
58
|
+
* from the host's executable when that is the runtime itself, else found on PATH, the host's runtime first.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveJavaScriptProcessCommand(
|
|
61
|
+
searchPath: string | undefined,
|
|
62
|
+
platform: NodeJS.Platform = process.platform,
|
|
63
|
+
hostRuntime: string = process.versions.bun === undefined ? "node" : "bun",
|
|
64
|
+
execPath: string = process.execPath,
|
|
65
|
+
): string {
|
|
66
|
+
const candidates = hostRuntime === "bun" ? ["bun", "node"] : ["node", "bun"];
|
|
67
|
+
const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
68
|
+
if (runtimeNameOf(execPath) !== undefined && isExecutable(execPath)) return execPath;
|
|
69
|
+
const pathValue = searchPath ?? process.env.PATH ?? "";
|
|
70
|
+
if (pathValue.trim().length === 0) throw new JavaScriptProcessRuntimeUnavailableError(hostRuntime, "");
|
|
71
|
+
const directories = pathValue.split(platform === "win32" ? ";" : ":").filter((directory) => directory.length > 0);
|
|
72
|
+
for (const name of candidates) {
|
|
73
|
+
for (const directory of directories) {
|
|
74
|
+
for (const extension of extensions) {
|
|
75
|
+
const candidate = join(directory, `${name}${extension}`);
|
|
76
|
+
if (isExecutable(candidate)) return candidate;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new JavaScriptProcessRuntimeUnavailableError(hostRuntime, pathValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The badge identity of the child a process-mode kernel runs: the host's own identity when the child is the host's
|
|
85
|
+
* executable, else the resolved runtime with the version it reports. Undefined when no runtime resolves (the kernel
|
|
86
|
+
* then reports the capability gap on its first cell).
|
|
87
|
+
*/
|
|
88
|
+
export function processRuntimeInfo(searchPath: string | undefined): EvalRuntimeInfo | undefined {
|
|
89
|
+
let command: string;
|
|
90
|
+
try {
|
|
91
|
+
command = resolveJavaScriptProcessCommand(searchPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof JavaScriptProcessRuntimeUnavailableError) return undefined;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
const name = runtimeNameOf(command);
|
|
97
|
+
if (command === process.execPath || name === undefined) return undefined;
|
|
98
|
+
const version = execFileSync(command, ["--version"], { encoding: "utf8", timeout: 5_000 }).trim().replace(/^v/, "");
|
|
99
|
+
return { name, version, path: command, isolation: "process" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** "bun" or "node" from an executable path; undefined for anything else (a compiled senpi binary, for one). */
|
|
103
|
+
export function runtimeNameOf(command: string): "bun" | "node" | undefined {
|
|
104
|
+
const name = basename(command).replace(/\.(exe|cmd|bat)$/i, "");
|
|
105
|
+
return name === "bun" || name === "node" ? name : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isExecutable(path: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
accessSync(path, constants.X_OK);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (
|
|
114
|
+
error instanceof Error &&
|
|
115
|
+
"code" in error &&
|
|
116
|
+
(error.code === "ENOENT" || error.code === "EACCES" || error.code === "ENOTDIR")
|
|
117
|
+
) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function spawnProcessWorker(url: URL, options: JavaScriptProcessWorkerOptions): JavaScriptProcessWorker {
|
|
125
|
+
const command = resolveJavaScriptProcessCommand(options.searchPath, process.platform, undefined, options.execPath);
|
|
126
|
+
const isBun = runtimeNameOf(command) === "bun";
|
|
127
|
+
const args = isBun ? [fileURLToPath(url)] : ["--experimental-strip-types", fileURLToPath(url)];
|
|
128
|
+
const env: NodeJS.ProcessEnv = {
|
|
129
|
+
...process.env,
|
|
130
|
+
...options.env,
|
|
131
|
+
SENPI_CODEMODE_PROCESS_CWD: options.cwd,
|
|
132
|
+
SENPI_CODEMODE_PROCESS_POOL_WIDTH: String(options.parallelPoolWidth),
|
|
133
|
+
};
|
|
134
|
+
const child =
|
|
135
|
+
options.spawn === undefined
|
|
136
|
+
? spawnSubprocess(undefined, { command, args, cwd: options.cwd, env })
|
|
137
|
+
: options.spawn(command, args, { cwd: options.cwd, env });
|
|
138
|
+
const messageHandlers = new Set<(message: KernelToHostMessage) => void>();
|
|
139
|
+
const errorHandlers = new Set<(error: Error) => void>();
|
|
140
|
+
const frameToken = randomBytes(16).toString("hex");
|
|
141
|
+
const framePrefix = `${frameToken} `;
|
|
142
|
+
let crashCause: Error | undefined;
|
|
143
|
+
let stderrTail = "";
|
|
144
|
+
// A line that fails to parse may be the end of a frame a dying child was writing; it is reported only once the
|
|
145
|
+
// channel carries on, so the crash cause or the exit itself, not "invalid frame", settles the cell.
|
|
146
|
+
let pendingInvalidFrame: Error | undefined;
|
|
147
|
+
const reportPendingInvalidFrame = () => {
|
|
148
|
+
const error = pendingInvalidFrame;
|
|
149
|
+
pendingInvalidFrame = undefined;
|
|
150
|
+
if (error !== undefined) for (const handler of [...errorHandlers]) handler(error);
|
|
151
|
+
};
|
|
152
|
+
// Attached directly, not through SubprocessProcess, which stops reading stderr as soon as the exit is observed.
|
|
153
|
+
child.stderr.on("data", (chunk: string | Buffer) => {
|
|
154
|
+
stderrTail = `${stderrTail}${String(chunk)}`.slice(-STDERR_TAIL_CHARS);
|
|
155
|
+
const crash = crashCauseFrom(stderrTail, frameToken);
|
|
156
|
+
if (crash !== undefined) crashCause = crash;
|
|
157
|
+
});
|
|
158
|
+
const subprocess = new SubprocessProcess(child, {
|
|
159
|
+
onLine: (_process, line) => {
|
|
160
|
+
// Only a line carrying this process's token is a frame; any other line is output that
|
|
161
|
+
// reached the channel (a raw fd write, a child process) and is delivered as text.
|
|
162
|
+
if (!line.startsWith(framePrefix)) {
|
|
163
|
+
if (line.trim().length === 0) return;
|
|
164
|
+
reportPendingInvalidFrame();
|
|
165
|
+
for (const handler of [...messageHandlers]) handler({ type: "text", stream: "stdout", data: line });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const parsed = decodeProcessFrame(line.slice(framePrefix.length), frameToken);
|
|
169
|
+
if (!parsed.ok) {
|
|
170
|
+
reportPendingInvalidFrame();
|
|
171
|
+
pendingInvalidFrame = new Error(
|
|
172
|
+
`JavaScript kernel process emitted an invalid frame: ${parsed.error.message}`,
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
reportPendingInvalidFrame();
|
|
177
|
+
const message = parsed.message;
|
|
178
|
+
if (!isKernelToHostMessage(message)) return;
|
|
179
|
+
for (const handler of [...messageHandlers]) handler(message);
|
|
180
|
+
},
|
|
181
|
+
// Crash causes are read by the listener below, which stays attached until stderr ends.
|
|
182
|
+
onStderr: () => {},
|
|
183
|
+
onExit: (_process, code, signal) => {
|
|
184
|
+
pendingInvalidFrame = undefined;
|
|
185
|
+
// A dead child's last stderr bytes can still be in flight when its exit is observed: settle once stderr has
|
|
186
|
+
// ended (bounded), so the cause it reported is not replaced by the bare exit.
|
|
187
|
+
void stderrEnded(child.stderr, STDERR_DRAIN_GRACE_MS).then(() => {
|
|
188
|
+
// The child's own report of what killed it, as worker mode reports a thread's error; else the exit itself.
|
|
189
|
+
const error = crashCause ?? new JavaScriptWorkerExitedError(code ?? -1, signal);
|
|
190
|
+
for (const handler of [...errorHandlers]) handler(error);
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
onError: (_process, error) => {
|
|
194
|
+
for (const handler of [...errorHandlers]) handler(error);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
// The token is the first line the entry reads, before any cell can run.
|
|
198
|
+
subprocess.send(`${frameToken}\n`);
|
|
199
|
+
return {
|
|
200
|
+
mode: "process",
|
|
201
|
+
get pid() {
|
|
202
|
+
return child.pid;
|
|
203
|
+
},
|
|
204
|
+
postMessage(message) {
|
|
205
|
+
subprocess.send(`${JSON.stringify(message)}\n`);
|
|
206
|
+
},
|
|
207
|
+
onMessage(handler) {
|
|
208
|
+
messageHandlers.add(handler);
|
|
209
|
+
return () => {
|
|
210
|
+
messageHandlers.delete(handler);
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
onError(handler) {
|
|
214
|
+
errorHandlers.add(handler);
|
|
215
|
+
return () => {
|
|
216
|
+
errorHandlers.delete(handler);
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
async terminate() {
|
|
220
|
+
const exited = await subprocess.shutdown(`${JSON.stringify({ type: "close" })}\n`);
|
|
221
|
+
if (!exited) await subprocess.terminate("SIGKILL", PROCESS_CLOSE_GRACE_MS);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const STDERR_TAIL_CHARS = 16 * 1024;
|
|
227
|
+
const STDERR_DRAIN_GRACE_MS = 1_000;
|
|
228
|
+
|
|
229
|
+
/** Resolves when the stream has ended or closed, or after the grace period, whichever comes first. */
|
|
230
|
+
function stderrEnded(stream: NodeJS.ReadableStream, graceMs: number): Promise<void> {
|
|
231
|
+
if (("readableEnded" in stream && stream.readableEnded === true) || ("closed" in stream && stream.closed === true)) {
|
|
232
|
+
return Promise.resolve();
|
|
233
|
+
}
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
const done = () => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
stream.removeListener("end", done);
|
|
238
|
+
stream.removeListener("close", done);
|
|
239
|
+
resolve();
|
|
240
|
+
};
|
|
241
|
+
const timer = setTimeout(done, graceMs);
|
|
242
|
+
stream.once("end", done);
|
|
243
|
+
stream.once("close", done);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const BIGINT_MARKER = "\u0000senpi:bigint:";
|
|
247
|
+
const UNDEFINED_MARKER = "\u0000senpi:undefined:";
|
|
248
|
+
const BIGINT_DIGITS = /^-?\d+$/;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A frame's `BigInt` and `undefined` values travel as markers (see process-entry.js `replacer`). They are revived
|
|
252
|
+
* before the frame is checked against the bridge schema: an optional field the child left `undefined` (a cell whose
|
|
253
|
+
* value is `undefined` has no `valueRepr`) arrives as a marker, and checked as-is it would fail the schema.
|
|
254
|
+
*/
|
|
255
|
+
function decodeProcessFrame(line: string, token: string): ReturnType<typeof validateBridgeMessage> {
|
|
256
|
+
const parsed = parseBridgeJsonLine(line);
|
|
257
|
+
if (!parsed.ok) return parsed;
|
|
258
|
+
const value = parsed.value;
|
|
259
|
+
if (typeof value === "object" && value !== null) reviveFrameValues(value, token);
|
|
260
|
+
return validateBridgeMessage(value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The cause the child reported on stderr just before exiting (see process-entry.js `reportCrash`). */
|
|
264
|
+
function crashCauseFrom(stderr: string, token: string): Error | undefined {
|
|
265
|
+
const prefix = `senpi-kernel-crash ${token} `;
|
|
266
|
+
const line = stderr.split("\n").find((candidate) => candidate.startsWith(prefix));
|
|
267
|
+
if (line === undefined) return undefined;
|
|
268
|
+
try {
|
|
269
|
+
const cause: unknown = JSON.parse(line.slice(prefix.length));
|
|
270
|
+
if (typeof cause !== "object" || cause === null || !("message" in cause) || typeof cause.message !== "string") {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const error = new Error(cause.message);
|
|
274
|
+
if ("name" in cause && typeof cause.name === "string") error.name = cause.name;
|
|
275
|
+
return error;
|
|
276
|
+
} catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Turns the child's markers back, in place, into the values JSON cannot carry (a BigInt, an `undefined` property or
|
|
283
|
+
* element), so a frame matches what worker mode's structured clone delivers. A marker carries this child's frame
|
|
284
|
+
* token, so a cell's own data that merely looks like one is delivered as it is.
|
|
285
|
+
*/
|
|
286
|
+
function reviveFrameValues(container: object, token: string): void {
|
|
287
|
+
const bigintKey = `${BIGINT_MARKER}${token}`;
|
|
288
|
+
const undefinedKey = `${UNDEFINED_MARKER}${token}`;
|
|
289
|
+
for (const key of Object.keys(container)) {
|
|
290
|
+
const value: unknown = Reflect.get(container, key);
|
|
291
|
+
if (typeof value !== "object" || value === null) continue;
|
|
292
|
+
const keys = Object.keys(value);
|
|
293
|
+
const digits: unknown = keys.length === 1 && keys[0] === bigintKey ? Reflect.get(value, bigintKey) : undefined;
|
|
294
|
+
if (typeof digits === "string" && BIGINT_DIGITS.test(digits)) Reflect.set(container, key, BigInt(digits));
|
|
295
|
+
else if (keys.length === 1 && keys[0] === undefinedKey) Reflect.set(container, key, undefined);
|
|
296
|
+
else reviveFrameValues(value, token);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cellOfRejection } from "./cell-run-context.js";
|
|
2
|
+
|
|
3
|
+
const STACK_FRAMES = 3;
|
|
4
|
+
|
|
5
|
+
// An unhandled rejection is reported, never fatal: the kernel keeps its variables (the Node REPL and Jupyter norm). The
|
|
6
|
+
// report names the cell that started the work when it is known and lands on the cell running now, or on the next one.
|
|
7
|
+
// A burst is folded into one report plus a count, so a stopped loop cannot flood a cell's output.
|
|
8
|
+
export function createRejectionReports({ activeCell, emitText }) {
|
|
9
|
+
let waiting = null;
|
|
10
|
+
const shownInCell = new WeakMap();
|
|
11
|
+
|
|
12
|
+
function report(reason) {
|
|
13
|
+
const text = describe(reason, cellOfRejection(reason), activeCell());
|
|
14
|
+
const cell = activeCell();
|
|
15
|
+
if (cell === null || cell.released) {
|
|
16
|
+
if (waiting === null) waiting = { text, more: 0 };
|
|
17
|
+
else waiting.more++;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
21
|
+
shownInCell.set(cell, shown + 1);
|
|
22
|
+
if (shown === 0) emitText(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
report,
|
|
27
|
+
startCell(cell) {
|
|
28
|
+
if (waiting === null) return;
|
|
29
|
+
emitText(`${waiting.text}${moreLine(waiting.more)}`);
|
|
30
|
+
shownInCell.set(cell, 1);
|
|
31
|
+
waiting = null;
|
|
32
|
+
},
|
|
33
|
+
finishCell(cell) {
|
|
34
|
+
const shown = shownInCell.get(cell) ?? 0;
|
|
35
|
+
if (shown > 1) emitText(moreLine(shown - 1));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function moreLine(more) {
|
|
41
|
+
return more === 0 ? "" : `... and ${more} more unhandled promise ${more === 1 ? "rejection" : "rejections"}\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describe(reason, origin, running) {
|
|
45
|
+
const where =
|
|
46
|
+
origin === undefined
|
|
47
|
+
? "(the cell that started it is unknown)"
|
|
48
|
+
: origin === running && !origin.released
|
|
49
|
+
? "in this cell"
|
|
50
|
+
: `from cell ${origin.cellId}${origin.released ? ", after it was stopped" : ""}`;
|
|
51
|
+
return `Unhandled promise rejection ${where}: ${summary(reason)}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summary(reason) {
|
|
55
|
+
if (!(reason instanceof Error)) return String(reason);
|
|
56
|
+
const frames = (reason.stack ?? "")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((line) => line.trim().startsWith("at "))
|
|
59
|
+
.slice(0, STACK_FRAMES);
|
|
60
|
+
return [`${reason.name}: ${reason.message}`, ...frames.map((line) => ` ${line.trim()}`)].join("\n");
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EvalStatusEvent, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import type { JavaScriptRunInput } from "./kernel-contract.ts";
|
|
3
|
+
import { crashedResult } from "./worker-host.ts";
|
|
3
4
|
|
|
4
5
|
type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
5
6
|
|
|
@@ -83,6 +84,12 @@ export class JavaScriptRunQueue {
|
|
|
83
84
|
return Math.max(0, Math.round(finishedAtMs - run.startedAtMs));
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** Settles `run` as crashed with `error`: the worker died under it. */
|
|
88
|
+
settleCrashed(run: PendingJavaScriptRun, error: Error): void {
|
|
89
|
+
this.releaseActive(run);
|
|
90
|
+
this.settle(run, crashedResult(run.input.cellId, error, this.durationMs(run, performance.now())));
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
releaseActive(run: PendingJavaScriptRun): boolean {
|
|
87
94
|
if (this.#active !== run) return false;
|
|
88
95
|
this.#active = null;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Host-side twin of windows-tree-kill.js (the worker imports that one; .ts files may not import .js). `taskkill /T`
|
|
4
|
+
// adopts every process whose recorded ParentProcessId equals the root's pid, and Windows never rewrites that
|
|
5
|
+
// field when a parent exits and reuses pids (senpi#2999). The tree is computed from one process listing instead: a process
|
|
6
|
+
// belongs to it only when it started at or after the parent it names, and each pid is killed by name.
|
|
7
|
+
|
|
8
|
+
const LISTING_TIMEOUT_MS = 5_000;
|
|
9
|
+
const LISTING_MAX_BUFFER = 16 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
const ROWS_SCRIPT = `Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object {
|
|
12
|
+
$created = if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 }
|
|
13
|
+
"{0} {1} {2} {3}" -f $_.ProcessId, $_.ParentProcessId, $created, $_.Name }`;
|
|
14
|
+
|
|
15
|
+
const PROTECTED_IMAGES = new Set([
|
|
16
|
+
"system",
|
|
17
|
+
"smss.exe",
|
|
18
|
+
"csrss.exe",
|
|
19
|
+
"wininit.exe",
|
|
20
|
+
"winlogon.exe",
|
|
21
|
+
"services.exe",
|
|
22
|
+
"lsass.exe",
|
|
23
|
+
"lsaiso.exe",
|
|
24
|
+
"svchost.exe",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export interface WindowsProcessRow {
|
|
28
|
+
readonly pid: number;
|
|
29
|
+
readonly parentPid: number;
|
|
30
|
+
readonly createdAt: bigint;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseWindowsRows(stdout: string): WindowsProcessRow[] {
|
|
35
|
+
const rows: WindowsProcessRow[] = [];
|
|
36
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
37
|
+
const [pidText, parentText, createdText, ...nameParts] = line.trim().split(/\s+/u);
|
|
38
|
+
const pid = Number(pidText);
|
|
39
|
+
const parentPid = Number(parentText);
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !/^\d+$/u.test(createdText ?? ""))
|
|
41
|
+
continue;
|
|
42
|
+
rows.push({ pid, parentPid, createdAt: BigInt(createdText ?? "0"), name: nameParts.join(" ") });
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The root and its genuine descendants; any protected image or ancestor of `selfPid` in it shrinks the kill to the root. */
|
|
48
|
+
export function windowsTreeKillPids(rows: readonly WindowsProcessRow[], rootPid: number, selfPid: number): number[] {
|
|
49
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
50
|
+
if (!byPid.has(rootPid)) return [];
|
|
51
|
+
const tree = new Set<number>([rootPid]);
|
|
52
|
+
let grew = true;
|
|
53
|
+
while (grew) {
|
|
54
|
+
grew = false;
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
if (tree.has(row.pid) || !tree.has(row.parentPid)) continue;
|
|
57
|
+
const parent = byPid.get(row.parentPid);
|
|
58
|
+
if (parent === undefined || row.createdAt < parent.createdAt) continue;
|
|
59
|
+
tree.add(row.pid);
|
|
60
|
+
grew = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const ancestors = new Set<number>([selfPid]);
|
|
64
|
+
for (let current = byPid.get(selfPid); current !== undefined; ) {
|
|
65
|
+
const parent = byPid.get(current.parentPid);
|
|
66
|
+
if (parent === undefined || ancestors.has(parent.pid) || parent.createdAt > current.createdAt) break;
|
|
67
|
+
ancestors.add(parent.pid);
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
const refused = [...tree].some(
|
|
71
|
+
(pid) => PROTECTED_IMAGES.has((byPid.get(pid)?.name ?? "").toLowerCase()) || ancestors.has(pid),
|
|
72
|
+
);
|
|
73
|
+
if (!refused) return [...tree];
|
|
74
|
+
const rootRefused = PROTECTED_IMAGES.has((byPid.get(rootPid)?.name ?? "").toLowerCase()) || ancestors.has(rootPid);
|
|
75
|
+
return rootRefused ? [] : [rootPid];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `taskkill` arguments: the checked tree pid by pid, or `/T` on the root when no listing could be read. */
|
|
79
|
+
export function windowsTreeKillArgs(
|
|
80
|
+
rootPid: number,
|
|
81
|
+
rows: readonly WindowsProcessRow[] | undefined,
|
|
82
|
+
selfPid: number = process.pid,
|
|
83
|
+
): string[] {
|
|
84
|
+
if (rows === undefined) return ["/F", "/T", "/PID", String(rootPid)];
|
|
85
|
+
const pids = windowsTreeKillPids(rows, rootPid, selfPid);
|
|
86
|
+
if (pids.length === 0) return [];
|
|
87
|
+
return ["/F", ...pids.flatMap((pid) => ["/PID", String(pid)])];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function listWindowsRows(): Promise<WindowsProcessRow[] | undefined> {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
execFile(
|
|
93
|
+
"powershell.exe",
|
|
94
|
+
["-NoProfile", "-NonInteractive", "-Command", ROWS_SCRIPT],
|
|
95
|
+
{ timeout: LISTING_TIMEOUT_MS, maxBuffer: LISTING_MAX_BUFFER, windowsHide: true },
|
|
96
|
+
(error, stdout) => {
|
|
97
|
+
const rows = error ? [] : parseWindowsRows(String(stdout ?? ""));
|
|
98
|
+
resolve(rows.length > 0 ? rows : undefined);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Kills the checked process tree rooted at `pid`; resolves once taskkill has run (or nothing needed killing).
|
|
106
|
+
* A batch passes one shared `listing` so every tree is planned from the same snapshot.
|
|
107
|
+
*/
|
|
108
|
+
export async function killWindowsTree(
|
|
109
|
+
pid: number,
|
|
110
|
+
listing: Promise<readonly WindowsProcessRow[] | undefined> = listWindowsRows(),
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const args = windowsTreeKillArgs(pid, await listing);
|
|
113
|
+
if (args.length === 0) return;
|
|
114
|
+
await new Promise<void>((resolve) => {
|
|
115
|
+
execFile("taskkill", args, { timeout: LISTING_TIMEOUT_MS, windowsHide: true }, () => resolve());
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface WindowsProcessRow {
|
|
2
|
+
readonly pid: number;
|
|
3
|
+
readonly parentPid: number;
|
|
4
|
+
readonly createdAt: bigint;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseWindowsRows(stdout: string): WindowsProcessRow[];
|
|
9
|
+
export function windowsTreeKillPids(rows: readonly WindowsProcessRow[], rootPid: number, selfPid: number): number[];
|
|
10
|
+
export function windowsTreeKillArgs(
|
|
11
|
+
rootPid: number,
|
|
12
|
+
rows: readonly WindowsProcessRow[] | undefined,
|
|
13
|
+
selfPid?: number,
|
|
14
|
+
): string[];
|
|
15
|
+
export function listWindowsRows(): Promise<WindowsProcessRow[] | undefined>;
|
|
16
|
+
export function killWindowsTree(
|
|
17
|
+
pid: number,
|
|
18
|
+
listing?: Promise<readonly WindowsProcessRow[] | undefined>,
|
|
19
|
+
): Promise<void>;
|