@code-yeongyu/senpi-codemode 2026.10.10-2 → 2026.10.10-4
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 +34 -0
- package/README.md +80 -2
- package/package.json +4 -4
- package/src/bridge/protocol.ts +8 -3
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +8 -1
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager.ts +9 -2
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/js/context-manager.ts +23 -24
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/kernel-contract.ts +14 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- 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 +462 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/worker-core.js +4 -2
- package/src/kernels/js/worker-cwd.js +5 -2
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +48 -1
- package/src/kernels/js/worker-webview.js +8 -0
- package/src/kernels/sandbox/sandbox-cell.ts +26 -1
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-manager.ts +4 -4
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/render.ts +2 -0
- package/src/tool/run-eval-cell.ts +3 -2
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -128,7 +128,7 @@ export function createWorkerCore(transport, options) {
|
|
|
128
128
|
}
|
|
129
129
|
if (message.type === "init") {
|
|
130
130
|
applySessionEnvironment(message.sessionEnv);
|
|
131
|
-
installSessionCwd(options.cwd);
|
|
131
|
+
installSessionCwd(options.cwd, options.cwdInstallOptions);
|
|
132
132
|
installKernelWebView(requestWebViewPort);
|
|
133
133
|
installPackageResolver();
|
|
134
134
|
runtime = new JsWorkerRuntime({
|
|
@@ -145,7 +145,9 @@ export function createWorkerCore(transport, options) {
|
|
|
145
145
|
if (activeCell?.interruption) acknowledgeInterrupt();
|
|
146
146
|
},
|
|
147
147
|
});
|
|
148
|
-
|
|
148
|
+
// A process-mode kernel measures its memory host-side as a footprint; the in-heap
|
|
149
|
+
// worker reading would contradict it, so the child keeps memory collection off.
|
|
150
|
+
if (message.memory && !options.processModeMemory) {
|
|
149
151
|
memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
|
|
150
152
|
memory.captureBaseline();
|
|
151
153
|
}
|
|
@@ -52,8 +52,11 @@ const FS_PATH_ARGS = {
|
|
|
52
52
|
const CHILD_PROCESS_FILE_ARGS = ["spawn", "spawnSync", "execFile", "execFileSync", "fork"];
|
|
53
53
|
const CHILD_PROCESS_COMMAND_ARGS = ["exec", "execSync"];
|
|
54
54
|
|
|
55
|
-
export function installSessionCwd(cwd) {
|
|
56
|
-
|
|
55
|
+
export function installSessionCwd(cwd, options) {
|
|
56
|
+
// A process-mode kernel child IS the main thread; the worker guard relaxes only for that entry.
|
|
57
|
+
if (isMainThread && options?.allowMainThread !== true) {
|
|
58
|
+
throw new Error("installSessionCwd must only run inside a kernel worker thread");
|
|
59
|
+
}
|
|
57
60
|
const root = resolve(cwd);
|
|
58
61
|
const rootBytes = Buffer.from(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
59
62
|
// fs also takes Buffer/Uint8Array paths; a relative one gets the session root prepended as bytes,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
2
|
import { Worker } from "node:worker_threads";
|
|
3
3
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
|
+
import { describeExit } from "../shared/kernel-death.ts";
|
|
4
5
|
import type { WorkerLike } from "./inline-worker.ts";
|
|
5
6
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
6
7
|
|
|
@@ -22,10 +23,16 @@ export class WorkerStartupCancelledError extends Error {
|
|
|
22
23
|
export class JavaScriptWorkerExitedError extends Error {
|
|
23
24
|
readonly name = "JavaScriptWorkerExitedError";
|
|
24
25
|
readonly exitCode: number;
|
|
26
|
+
readonly exitSignal: string | null;
|
|
25
27
|
|
|
26
|
-
constructor(exitCode: number) {
|
|
27
|
-
super(
|
|
28
|
+
constructor(exitCode: number, exitSignal: string | null = null) {
|
|
29
|
+
super(
|
|
30
|
+
exitSignal === null
|
|
31
|
+
? `JavaScript worker exited with code ${exitCode}`
|
|
32
|
+
: `JavaScript worker exited with ${describeExit(null, exitSignal)} (code ${exitCode})`,
|
|
33
|
+
);
|
|
28
34
|
this.exitCode = exitCode;
|
|
35
|
+
this.exitSignal = exitSignal;
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -4,6 +4,7 @@ import type { WorkerLike } from "./inline-worker.ts";
|
|
|
4
4
|
import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
5
5
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
6
6
|
import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
7
|
+
import type { JavaScriptProcessWorker } from "./process-worker.ts";
|
|
7
8
|
import { KernelWebViewClients } from "./webview-host.ts";
|
|
8
9
|
import { WorkerStartupCancelledError } from "./worker-host.ts";
|
|
9
10
|
import { startWorkerWithInlineFallback } from "./worker-startup.ts";
|
|
@@ -19,6 +20,7 @@ export class WorkerSlot {
|
|
|
19
20
|
readonly #options: JavaScriptKernelOptions;
|
|
20
21
|
readonly #listeners: WorkerSlotListeners;
|
|
21
22
|
#worker: WorkerLike | null = null;
|
|
23
|
+
#lastProcessPid: number | undefined;
|
|
22
24
|
#webViews: KernelWebViewClients | null = null;
|
|
23
25
|
#mode: JavaScriptKernelMode = "worker";
|
|
24
26
|
#generation = 0;
|
|
@@ -34,6 +36,12 @@ export class WorkerSlot {
|
|
|
34
36
|
return this.#mode;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
get processPid(): number | undefined {
|
|
40
|
+
const worker = this.#worker;
|
|
41
|
+
if (worker?.mode === "process") return (worker as JavaScriptProcessWorker).pid;
|
|
42
|
+
return this.#lastProcessPid;
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
get present(): boolean {
|
|
38
46
|
return this.#worker !== null;
|
|
39
47
|
}
|
|
@@ -105,6 +113,7 @@ export class WorkerSlot {
|
|
|
105
113
|
#publish(worker: WorkerLike, generation: number): void {
|
|
106
114
|
if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
107
115
|
this.#worker = worker;
|
|
116
|
+
if (worker.mode === "process") this.#lastProcessPid = (worker as JavaScriptProcessWorker).pid;
|
|
108
117
|
const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
|
|
109
118
|
this.#webViews = webViews;
|
|
110
119
|
worker.onMessage((message) => {
|
|
@@ -2,8 +2,10 @@ import { dirname, join } from "node:path";
|
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
3
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
4
4
|
import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
|
|
5
|
+
import { retireWorker } from "./interrupt-bounds.ts";
|
|
5
6
|
import { resolveKernelToolNameSource } from "./kernel-contract.ts";
|
|
6
7
|
import { type JavaScriptKernelOptions, localBridgeConnection } from "./local-module-loader.ts";
|
|
8
|
+
import { resolveJsProcessEntryUrl, spawnProcessWorker } from "./process-worker.ts";
|
|
7
9
|
import { spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
|
|
8
10
|
|
|
9
11
|
export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
|
|
@@ -15,6 +17,21 @@ export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions
|
|
|
15
17
|
return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How long a process-mode kernel child gets to report ready. A child that a starved host never schedules would
|
|
22
|
+
* otherwise hold every queued cell forever: no cell's own timeout is armed until the kernel is up.
|
|
23
|
+
*/
|
|
24
|
+
export const PROCESS_STARTUP_DEADLINE_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
export class ProcessKernelStartupTimeoutError extends Error {
|
|
27
|
+
constructor(deadlineMs: number) {
|
|
28
|
+
super(
|
|
29
|
+
`JavaScript kernel process did not become ready within ${deadlineMs / 1000}s; it was stopped, and the next cell starts a fresh one`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "ProcessKernelStartupTimeoutError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
export interface WorkerStartupHooks {
|
|
19
36
|
readonly options: JavaScriptKernelOptions;
|
|
20
37
|
readonly kernelGeneration: number;
|
|
@@ -36,6 +53,13 @@ export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, s
|
|
|
36
53
|
await worker.terminate();
|
|
37
54
|
throw new WorkerStartupCancelledError();
|
|
38
55
|
}
|
|
56
|
+
// Process mode never falls back: the user asked for isolation, so a failed start stays visible. The child that
|
|
57
|
+
// failed to start is stopped (bounded), so a stuck one is not left behind.
|
|
58
|
+
if (worker.mode === "process") {
|
|
59
|
+
hooks.retire(worker);
|
|
60
|
+
await retireWorker(worker);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
39
63
|
if (worker.mode === "inline") throw error;
|
|
40
64
|
hooks.retire(worker);
|
|
41
65
|
await worker.terminate();
|
|
@@ -47,6 +71,15 @@ export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, s
|
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
|
|
74
|
+
if (options.isolation === "process") {
|
|
75
|
+
const url = resolveJsProcessEntryUrl();
|
|
76
|
+
return spawnProcessWorker(url, {
|
|
77
|
+
cwd: options.cwd,
|
|
78
|
+
parallelPoolWidth: options.parallelPoolWidth,
|
|
79
|
+
...(options.processCommandPath === undefined ? {} : { searchPath: options.processCommandPath }),
|
|
80
|
+
...(options.processExecPath === undefined ? {} : { execPath: options.processExecPath }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
50
83
|
try {
|
|
51
84
|
const url = options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
|
|
52
85
|
return spawnNodeWorker(url, options.cwd, options.parallelPoolWidth);
|
|
@@ -57,7 +90,10 @@ function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
|
|
|
57
90
|
}
|
|
58
91
|
|
|
59
92
|
async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, signal: AbortSignal): Promise<void> {
|
|
60
|
-
const ready =
|
|
93
|
+
const ready =
|
|
94
|
+
worker.mode === "process"
|
|
95
|
+
? withStartupDeadline(waitForReady(worker, signal), hooks.options)
|
|
96
|
+
: waitForReady(worker, signal);
|
|
61
97
|
const options = hooks.options;
|
|
62
98
|
worker.postMessage({
|
|
63
99
|
type: "init",
|
|
@@ -72,3 +108,14 @@ async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, s
|
|
|
72
108
|
});
|
|
73
109
|
await ready;
|
|
74
110
|
}
|
|
111
|
+
|
|
112
|
+
function withStartupDeadline(ready: Promise<void>, options: JavaScriptKernelOptions): Promise<void> {
|
|
113
|
+
const deadlineMs = options.processStartupDeadlineMs ?? PROCESS_STARTUP_DEADLINE_MS;
|
|
114
|
+
let timer: NodeJS.Timeout | undefined;
|
|
115
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
116
|
+
timer = setTimeout(() => reject(new ProcessKernelStartupTimeoutError(deadlineMs)), deadlineMs);
|
|
117
|
+
});
|
|
118
|
+
// When the deadline wins, the stopped child's readiness promise rejects later; that outcome is already reported.
|
|
119
|
+
ready.catch(() => {});
|
|
120
|
+
return Promise.race([ready, expired]).finally(() => clearTimeout(timer));
|
|
121
|
+
}
|
|
@@ -5,6 +5,7 @@ import { DELIVER_EVENT, WebViewPortClient, webViewError } from "./worker-webview
|
|
|
5
5
|
// `Bun` parameter bound to a stand-in whose `WebView` forwards Chrome-backed views to the
|
|
6
6
|
// main-thread service over a private port; every other `Bun.*` member is the real one.
|
|
7
7
|
const KERNEL_BUN = Symbol.for("senpi.kernel.bun");
|
|
8
|
+
const KERNEL_PROCESS_MODE = Symbol.for("senpi.kernel.processMode");
|
|
8
9
|
|
|
9
10
|
export function bindKernelBun(source) {
|
|
10
11
|
if (!globalThis[KERNEL_BUN]) return source;
|
|
@@ -17,6 +18,10 @@ export function installKernelWebView(requestPort) {
|
|
|
17
18
|
globalThis[KERNEL_BUN] = shadowBun(bun, createKernelWebView(bun.WebView, requestPort));
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
export function markKernelProcessMode() {
|
|
22
|
+
globalThis[KERNEL_PROCESS_MODE] = true;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
function shadowBun(real, WebView) {
|
|
21
26
|
return new Proxy(
|
|
22
27
|
{},
|
|
@@ -35,7 +40,10 @@ function shadowBun(real, WebView) {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
// The macOS default (WebKit) stays a native worker view: its host process serves any thread.
|
|
43
|
+
// A process-mode kernel already runs its cells on the child's main thread, so the native
|
|
44
|
+
// WebView constructs there directly and no MessagePort proxy is ever needed.
|
|
38
45
|
function needsMainThread(options) {
|
|
46
|
+
if (globalThis[KERNEL_PROCESS_MODE]) return false;
|
|
39
47
|
const backend = options?.backend;
|
|
40
48
|
if (backend === undefined) return process.platform !== "darwin";
|
|
41
49
|
return backend === "chrome" || (typeof backend === "object" && backend !== null && backend.type === "chrome");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedSandbox } from "../../config/feature-settings.ts";
|
|
2
2
|
import { marshalToolResult } from "../../tool/image.ts";
|
|
3
|
-
import type { ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
import type { EvalRuntimeInfo, ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
|
|
4
4
|
import type { CodemodeSandbox } from "./vendor/pi-codemode/runtime/host.ts";
|
|
5
5
|
import type { CodemodeError, CodemodeOutputFrame, CodemodeTool } from "./vendor/pi-codemode/types.ts";
|
|
6
6
|
|
|
@@ -13,6 +13,31 @@ export interface SandboxCellOptions {
|
|
|
13
13
|
readonly wasmPath?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The runtime an isolated cell's result reports: the QuickJS build it runs on, never the persistent kernel's (senpi#2811).
|
|
18
|
+
* Read from the installed quickjs-wasi package the wasm loads from, only when an isolated cell runs; when that package
|
|
19
|
+
* cannot be resolved the cell itself fails with eval_isolate_unavailable, and the version says so.
|
|
20
|
+
*/
|
|
21
|
+
export function sandboxRuntimeInfo(): EvalRuntimeInfo {
|
|
22
|
+
let version = "unavailable";
|
|
23
|
+
try {
|
|
24
|
+
// getBuiltinModule, not a static import: node:module would otherwise load with the extension.
|
|
25
|
+
const manifest: unknown = process.getBuiltinModule("node:module").createRequire(import.meta.url)(
|
|
26
|
+
"quickjs-wasi/package.json",
|
|
27
|
+
);
|
|
28
|
+
if (
|
|
29
|
+
typeof manifest === "object" &&
|
|
30
|
+
manifest !== null &&
|
|
31
|
+
"version" in manifest &&
|
|
32
|
+
typeof manifest.version === "string"
|
|
33
|
+
)
|
|
34
|
+
version = manifest.version;
|
|
35
|
+
} catch {
|
|
36
|
+
// Reported as "unavailable": the same missing package makes the cell fail with eval_isolate_unavailable.
|
|
37
|
+
}
|
|
38
|
+
return { name: "quickjs", version, isolation: "sandbox" };
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
// On the script's first line so reported line numbers still match the cell; globals, so a cell's own
|
|
17
42
|
// `const print` shadows them instead of failing as a redeclaration.
|
|
18
43
|
const OUTPUT_ALIASES =
|
|
@@ -151,6 +151,11 @@ export class SubprocessProcess {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
private kill(signal: NodeJS.Signals): void {
|
|
154
|
+
if (globalThis.process.platform === "win32") {
|
|
155
|
+
if (this.child.pid !== undefined) killProcessTree(this.child.pid);
|
|
156
|
+
else this.child.kill(signal);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
154
159
|
if (this.child.pid !== undefined) {
|
|
155
160
|
try {
|
|
156
161
|
globalThis.process.kill(-this.child.pid, signal);
|
|
@@ -163,6 +168,15 @@ export class SubprocessProcess {
|
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
// Windows has no process-group signal: taskkill /T /F walks the tree rooted at the child.
|
|
172
|
+
function killProcessTree(pid: number): void {
|
|
173
|
+
try {
|
|
174
|
+
nodeSpawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!(error instanceof Error)) throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
export function spawnSubprocess(spawn: SubprocessSpawn | undefined, request: SubprocessSpawnRequest): SubprocessLike {
|
|
167
181
|
if (spawn) return spawn(request.command, request.args, { cwd: request.cwd, env: request.env });
|
|
168
182
|
const child = nodeSpawn(request.command, [...request.args], {
|
package/src/tool/AGENTS.md
CHANGED
|
@@ -17,6 +17,7 @@ anchor most suites).
|
|
|
17
17
|
| Code preview layout | `code-preview.ts` -> `display-code.ts` (JS: `display-js*.ts` Bun-only masked `Bun.Transpiler`; Python: `display-python*.ts` user interpreter, background + repaint) |
|
|
18
18
|
| Status events, execution events | `status-events.ts`, `eval-execution-event.ts` |
|
|
19
19
|
| Interrupt, capture | `interrupt-note.ts`, `call-capture.ts` |
|
|
20
|
+
| Magic cells | `magic-cells.ts` (parsing), `magic-cell-host.ts` (`%pip`, `%bun`/`%npm`, `%environment`), `load-cell.ts` (`%load`); installs live in `../environments/` |
|
|
20
21
|
|
|
21
22
|
## CONVENTIONS
|
|
22
23
|
|
package/src/tool/cell-runtime.ts
CHANGED
|
@@ -219,8 +219,10 @@ export class CellResultBuilder {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
#liveUpdateText(): string {
|
|
222
|
-
if (this.#state.status === "queued" && this.#state.queuedBehind
|
|
223
|
-
return
|
|
222
|
+
if (this.#state.status === "queued" && this.#state.queuedBehind !== undefined) {
|
|
223
|
+
return this.#state.queuedBehind.length === 0
|
|
224
|
+
? `waiting for the ${this.#state.input.language} kernel to be ready`
|
|
225
|
+
: `queued behind ${this.#state.queuedBehind.join(", ")} in the ${this.#state.input.language} kernel`;
|
|
224
226
|
}
|
|
225
227
|
const summary = this.#state.input.summary === undefined ? "" : ` ${this.#state.input.summary}`;
|
|
226
228
|
const output = this.#liveLines.text();
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
EvalDetachedCellSnapshot,
|
|
14
14
|
EvalDetachedCellStatusEntry,
|
|
15
15
|
} from "./detached-cell-contract.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { cancelledDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
|
|
17
17
|
import {
|
|
18
18
|
activeDetachedCellReuseError,
|
|
19
19
|
allowsDetachedCellTransition,
|
|
@@ -260,7 +260,7 @@ export class EvalDetachedCellManager {
|
|
|
260
260
|
return;
|
|
261
261
|
}
|
|
262
262
|
if (foreground) {
|
|
263
|
-
if (this.#settle(cell, "cancelled",
|
|
263
|
+
if (this.#settle(cell, "cancelled", cancelledDetachedResult(cell))) cell.onKill?.(expiry.error);
|
|
264
264
|
return;
|
|
265
265
|
}
|
|
266
266
|
await this.#cancel(cell, expiry.error.message);
|
|
@@ -269,7 +269,7 @@ export class EvalDetachedCellManager {
|
|
|
269
269
|
#cancelQueued(cell: ManagedCell, reason: string, error = new Error(reason)): void {
|
|
270
270
|
const dequeued = cell.kernel?.cancelQueued(cell.cellId, reason) ?? false;
|
|
271
271
|
cell.stateRetained = true;
|
|
272
|
-
this.#settle(cell, "cancelled",
|
|
272
|
+
this.#settle(cell, "cancelled", cancelledDetachedResult(cell));
|
|
273
273
|
// Acquisition/reset has no queue promise yet; its execution has no interrupt target either.
|
|
274
274
|
if (!dequeued) cell.onKill?.(error);
|
|
275
275
|
}
|
|
@@ -278,7 +278,7 @@ export class EvalDetachedCellManager {
|
|
|
278
278
|
const outcome = Promise.withResolvers<void>();
|
|
279
279
|
cell.interruptOutcome = outcome;
|
|
280
280
|
try {
|
|
281
|
-
if (!this.#settle(cell, "cancelled",
|
|
281
|
+
if (!this.#settle(cell, "cancelled", cancelledDetachedResult(cell)) || cell.kernel === undefined) return;
|
|
282
282
|
const handle = await cell.kernel.interrupt(reason, cell.cellId);
|
|
283
283
|
cell.interruptNote = handle.note;
|
|
284
284
|
cell.stateRetained = await handle.stateRetained;
|