mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
fix(codex): reap sandbox process trees before termination (#125908)
This commit is contained in:
committed by
GitHub
parent
fd216cb550
commit
fd8326c5bf
@@ -2,6 +2,28 @@ import type { OpenClawExecServer } from "./sandbox-exec-server/types.js";
|
||||
|
||||
export const sandboxExecServerRegistry = {
|
||||
servers: new Map<string, Promise<OpenClawExecServer>>(),
|
||||
async close(server: OpenClawExecServer): Promise<void> {
|
||||
if (server.closed) {
|
||||
return;
|
||||
}
|
||||
server.closed = true;
|
||||
for (const client of server.server.clients) {
|
||||
client.close(1001, "shutdown");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.server.close(() => resolve());
|
||||
});
|
||||
const cleanup = await Promise.allSettled([
|
||||
...server.cleanupTasks,
|
||||
...[...server.children].map(async (child) => await child.terminate()),
|
||||
]);
|
||||
const failures = cleanup.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "Codex sandbox exec-server child cleanup failed");
|
||||
}
|
||||
},
|
||||
async closeAll(): Promise<void> {
|
||||
const servers = await Promise.allSettled(this.servers.values());
|
||||
this.servers.clear();
|
||||
@@ -12,16 +34,7 @@ export const sandboxExecServerRegistry = {
|
||||
}
|
||||
const server = entry.value;
|
||||
server.refCount = 0;
|
||||
if (server.closed) {
|
||||
return;
|
||||
}
|
||||
server.closed = true;
|
||||
for (const client of server.server.clients) {
|
||||
client.close(1001, "shutdown");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.server.close(() => resolve());
|
||||
});
|
||||
await this.close(server);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const killProcessTreeMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
@@ -14,10 +15,17 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
spawn: (...args: Parameters<typeof actual.spawn>) => spawnMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/process-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
|
||||
import { httpRequest } from "./sandbox-exec-server/http.js";
|
||||
import { startProcess } from "./sandbox-exec-server/processes.js";
|
||||
import { startProcess, terminateProcess } from "./sandbox-exec-server/processes.js";
|
||||
import type { ManagedProcess, OpenClawExecServer } from "./sandbox-exec-server/types.js";
|
||||
|
||||
type FakeSocket = WebSocket & { send: ReturnType<typeof vi.fn> };
|
||||
@@ -40,7 +48,7 @@ function createFakeSocket(): FakeSocket {
|
||||
}
|
||||
|
||||
function createExecServer(sandbox: SandboxContext): OpenClawExecServer {
|
||||
return { sandbox } as OpenClawExecServer;
|
||||
return { sandbox, children: new Set(), cleanupTasks: new Set() } as OpenClawExecServer;
|
||||
}
|
||||
|
||||
function processStartParams(processId: string) {
|
||||
@@ -65,10 +73,223 @@ function streamingHttpParams(requestId: string) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
spawnMock.mockReset();
|
||||
killProcessTreeMock.mockReset();
|
||||
});
|
||||
|
||||
describe("Codex sandbox exec-server lifecycle", () => {
|
||||
it("reaps and finalizes a TERM-resistant child before acknowledging termination", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
let finishFinalize: (() => void) | undefined;
|
||||
const finalizeExec = vi.fn(
|
||||
async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
finishFinalize = resolve;
|
||||
}),
|
||||
);
|
||||
const sandbox = createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["sandbox-child"],
|
||||
env: {},
|
||||
finalizeToken: "terminate-token",
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
});
|
||||
const processes = new Map<string, ManagedProcess>();
|
||||
await startProcess(
|
||||
createExecServer(sandbox),
|
||||
processes,
|
||||
createFakeSocket(),
|
||||
processStartParams("process-resistant"),
|
||||
);
|
||||
killProcessTreeMock.mockImplementation(() => {
|
||||
setTimeout(() => child.emit("close", null, "SIGKILL"), 1_000);
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const termination = Promise.resolve(
|
||||
terminateProcess(processes, { processId: "process-resistant" }),
|
||||
).then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(killProcessTreeMock).toHaveBeenCalledWith(child.pid, {
|
||||
detached: process.platform !== "win32",
|
||||
graceMs: 1_000,
|
||||
});
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(finalizeExec).toHaveBeenCalledOnce();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishFinalize?.();
|
||||
await expect(termination).resolves.toEqual({ running: true });
|
||||
expect(finalizeExec).toHaveBeenCalledWith({
|
||||
status: "completed",
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
token: "terminate-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves cooperative TERM exit without force killing", async () => {
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
killProcessTreeMock.mockImplementation(() => child.emit("close", 143, "SIGTERM"));
|
||||
const finalizeExec = vi.fn(async () => undefined);
|
||||
const processes = new Map<string, ManagedProcess>();
|
||||
await startProcess(
|
||||
createExecServer(
|
||||
createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["sandbox-child"],
|
||||
env: {},
|
||||
finalizeToken: "cooperative-token",
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
}),
|
||||
),
|
||||
processes,
|
||||
createFakeSocket(),
|
||||
processStartParams("process-cooperative"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
terminateProcess(processes, { processId: "process-cooperative" }),
|
||||
).resolves.toEqual({ running: true });
|
||||
|
||||
expect(killProcessTreeMock).toHaveBeenCalledOnce();
|
||||
expect(finalizeExec).toHaveBeenCalledWith({
|
||||
status: "completed",
|
||||
exitCode: 143,
|
||||
timedOut: false,
|
||||
token: "cooperative-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("shares termination and finalization across concurrent cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
killProcessTreeMock.mockImplementation(() => {
|
||||
setTimeout(() => child.emit("close", null, "SIGKILL"), 1_000);
|
||||
});
|
||||
const finalizeExec = vi.fn(async () => undefined);
|
||||
const processes = new Map<string, ManagedProcess>();
|
||||
await startProcess(
|
||||
createExecServer(
|
||||
createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["sandbox-child"],
|
||||
env: {},
|
||||
finalizeToken: "race-token",
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
}),
|
||||
),
|
||||
processes,
|
||||
createFakeSocket(),
|
||||
processStartParams("process-race"),
|
||||
);
|
||||
|
||||
const first = terminateProcess(processes, { processId: "process-race" });
|
||||
const second = terminateProcess(processes, { processId: "process-race" });
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ running: true },
|
||||
{ running: true },
|
||||
]);
|
||||
expect(killProcessTreeMock).toHaveBeenCalledOnce();
|
||||
expect(finalizeExec).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a surviving tree instead of acknowledging termination", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
killProcessTreeMock.mockImplementation(() => undefined);
|
||||
const finalizeExec = vi.fn(async () => undefined);
|
||||
const processes = new Map<string, ManagedProcess>();
|
||||
await startProcess(
|
||||
createExecServer(
|
||||
createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["sandbox-child"],
|
||||
env: {},
|
||||
finalizeToken: "survivor-token",
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
}),
|
||||
),
|
||||
processes,
|
||||
createFakeSocket(),
|
||||
processStartParams("process-survivor"),
|
||||
);
|
||||
|
||||
const termination = terminateProcess(processes, { processId: "process-survivor" });
|
||||
const rejection = expect(termination).rejects.toThrow(
|
||||
`Sandbox child process tree ${child.pid} survived SIGKILL; tear down the sandbox environment and inspect the surviving process tree before retrying.`,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(4_500);
|
||||
|
||||
await rejection;
|
||||
expect(finalizeExec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reaps a TERM-resistant streaming HTTP child on socket close", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
killProcessTreeMock.mockImplementation(() => {
|
||||
setTimeout(() => child.emit("close", null, "SIGKILL"), 1_000);
|
||||
});
|
||||
const finalizeExec = vi.fn(async () => undefined);
|
||||
const socket = createFakeSocket();
|
||||
const request = httpRequest(
|
||||
createExecServer(
|
||||
createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["sandbox-http-child"],
|
||||
env: {},
|
||||
finalizeToken: "http-terminate-token",
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
}),
|
||||
),
|
||||
socket,
|
||||
streamingHttpParams("http-resistant"),
|
||||
);
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||||
(child.stdout as PassThrough).write(
|
||||
`${JSON.stringify({ type: "headers", status: 200, headers: [] })}\n`,
|
||||
);
|
||||
await expect(request).resolves.toEqual({ status: 200, headers: [], bodyBase64: "" });
|
||||
|
||||
socket.emit("close");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(killProcessTreeMock).toHaveBeenCalledOnce();
|
||||
expect(finalizeExec).toHaveBeenCalledOnce();
|
||||
expect(finalizeExec).toHaveBeenCalledWith({
|
||||
status: "failed",
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
token: "http-terminate-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the process backend lease after child error until close", async () => {
|
||||
const child = createFakeChild();
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
@@ -44,6 +44,27 @@ function echoFirstInputLineScript(prefix: string): string {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function readStartedPid(
|
||||
socket: Awaited<ReturnType<typeof openSocket>>,
|
||||
processId: string,
|
||||
): Promise<number> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const read = (await rpc(socket, "process/read", {
|
||||
processId,
|
||||
afterSeq: 0,
|
||||
waitMs: 100,
|
||||
})) as { chunks?: Array<{ chunk: string }> };
|
||||
const output = (read.chunks ?? [])
|
||||
.map((chunk) => Buffer.from(chunk.chunk, "base64").toString("utf8"))
|
||||
.join("");
|
||||
const pid = /PID=(\d+)/u.exec(output)?.[1];
|
||||
if (pid) {
|
||||
return Number(pid);
|
||||
}
|
||||
}
|
||||
throw new Error(`process ${processId} did not report its PID`);
|
||||
}
|
||||
|
||||
describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
it("reports unavailable app-server remote environment support without exposing an environment", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
@@ -213,7 +234,12 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
expect(buildExecSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "'/bin/sh' '-lc' 'printf ok'",
|
||||
env: { POLICY_ONLY: "1", POLICY_SET: "env-wins", TEST_FLAG: "1" },
|
||||
env: expect.objectContaining({
|
||||
CODEX_SANDBOX_EXEC_ID: expect.any(String),
|
||||
POLICY_ONLY: "1",
|
||||
POLICY_SET: "env-wins",
|
||||
TEST_FLAG: "1",
|
||||
}),
|
||||
usePty: false,
|
||||
workdir: "/workspace",
|
||||
}),
|
||||
@@ -463,11 +489,10 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
arg0: null,
|
||||
});
|
||||
|
||||
expect(buildExecSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: {},
|
||||
}),
|
||||
);
|
||||
const [{ env: execEnv }] = buildExecSpec.mock.calls[0] as unknown as [
|
||||
{ env: Record<string, string> },
|
||||
];
|
||||
expect(execEnv).toEqual({ CODEX_SANDBOX_EXEC_ID: expect.any(String) });
|
||||
socket.close();
|
||||
});
|
||||
|
||||
@@ -626,6 +651,79 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
await expect(openSocket(execServerUrl)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"reaps TERM-resistant process groups on socket loss and turn environment release",
|
||||
async () => {
|
||||
for (const cleanup of ["socket", "environment"] as const) {
|
||||
const finalizeExec = vi.fn(async () => undefined);
|
||||
const sandbox = createSandboxContext({
|
||||
buildExecSpec: async () => ({
|
||||
argv: [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
'echo "PID=$$"; trap -- "" TERM; while :; do echo heartbeat; sleep 0.1; done',
|
||||
],
|
||||
env: testExecEnv(),
|
||||
finalizeToken: `${cleanup}-lease`,
|
||||
stdinMode: "pipe-closed",
|
||||
}),
|
||||
finalizeExec,
|
||||
});
|
||||
sandbox.runtimeId = `openclaw-test-runtime-${cleanup}`;
|
||||
const client = createClient();
|
||||
await ensureCodexSandboxExecServerEnvironment({
|
||||
client: client as never,
|
||||
sandbox,
|
||||
});
|
||||
const socket = await openSocket(execServerUrlFromClient(client));
|
||||
let pid: number | undefined;
|
||||
try {
|
||||
await rpc(socket, "initialize", { clientName: "test" });
|
||||
socket.send(JSON.stringify({ method: "initialized" }));
|
||||
await rpc(socket, "process/start", {
|
||||
processId: `process-${cleanup}`,
|
||||
argv: ["ignored"],
|
||||
cwd: "file:///workspace",
|
||||
env: {},
|
||||
tty: false,
|
||||
pipeStdin: false,
|
||||
arg0: null,
|
||||
});
|
||||
pid = await readStartedPid(socket, `process-${cleanup}`);
|
||||
|
||||
if (cleanup === "socket") {
|
||||
const closed = waitForSocketClose(socket);
|
||||
socket.terminate();
|
||||
await closed;
|
||||
await vi.waitFor(() => expect(finalizeExec).toHaveBeenCalledOnce(), {
|
||||
timeout: 3_000,
|
||||
});
|
||||
} else {
|
||||
await releaseCodexSandboxExecServerEnvironment(sandbox);
|
||||
}
|
||||
|
||||
expect(() => process.kill(pid!, 0)).toThrow();
|
||||
expect(finalizeExec).toHaveBeenCalledOnce();
|
||||
expect(finalizeExec).toHaveBeenCalledWith({
|
||||
status: "completed",
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
token: `${cleanup}-lease`,
|
||||
});
|
||||
} finally {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// The owner already reaped the process group.
|
||||
}
|
||||
}
|
||||
await releaseCodexSandboxExecServerEnvironment(sandbox);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a shared exec-server open when another turn reacquires during release", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
|
||||
@@ -193,6 +193,8 @@ async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenCla
|
||||
url,
|
||||
sandbox,
|
||||
server,
|
||||
children: new Set(),
|
||||
cleanupTasks: new Set(),
|
||||
};
|
||||
server.on("connection", (socket, request) => {
|
||||
// ws emits error for maxPayload rejections before auth or JSON-RPC sees the frame.
|
||||
@@ -228,20 +230,7 @@ async function releaseOpenClawExecServer(execServer: OpenClawExecServer): Promis
|
||||
if (current === execServer) {
|
||||
sandboxExecServerRegistry.servers.delete(execServer.sandbox.runtimeId);
|
||||
}
|
||||
await closeOpenClawExecServer(execServer);
|
||||
}
|
||||
|
||||
async function closeOpenClawExecServer(execServer: OpenClawExecServer): Promise<void> {
|
||||
if (execServer.closed) {
|
||||
return;
|
||||
}
|
||||
execServer.closed = true;
|
||||
for (const client of execServer.server.clients) {
|
||||
client.close(1001, "shutdown");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
execServer.server.close(() => resolve());
|
||||
});
|
||||
await sandboxExecServerRegistry.close(execServer);
|
||||
}
|
||||
|
||||
function buildEnvironmentId(sandbox: SandboxContext): string {
|
||||
@@ -267,9 +256,21 @@ function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): vo
|
||||
});
|
||||
socket.on("close", () => {
|
||||
closeAllFileReads(fileReads);
|
||||
for (const process of processes.values()) {
|
||||
process.abortController.abort();
|
||||
}
|
||||
const cleanup = Promise.all(
|
||||
[...processes].map(async ([processId]) => {
|
||||
await terminateProcess(processes, { processId });
|
||||
}),
|
||||
).then(() => undefined);
|
||||
execServer.cleanupTasks.add(cleanup);
|
||||
void cleanup.then(
|
||||
() => execServer.cleanupTasks.delete(cleanup),
|
||||
(error: unknown) => {
|
||||
execServer.cleanupTasks.delete(cleanup);
|
||||
embeddedAgentLog.warn("codex sandbox exec-server socket cleanup failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,7 +342,7 @@ async function dispatchRequest(
|
||||
case "process/write":
|
||||
return writeProcess(processes, request.params);
|
||||
case "process/terminate":
|
||||
return terminateProcess(processes, request.params);
|
||||
return await terminateProcess(processes, request.params);
|
||||
case "fs/open":
|
||||
return await openFile(execServer, fileReads, request.params);
|
||||
case "fs/readBlock":
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
* Implements sandboxed HTTP requests for Codex native tools by routing network
|
||||
* access through the active OpenClaw sandbox backend.
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
|
||||
import { SsrFBlockedError, isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { JsonObject, JsonValue } from "../protocol.js";
|
||||
import { readHttpHeaders, requireNumber, requireObject, requireString } from "./json-rpc.js";
|
||||
import { requireBackend } from "./runtime.js";
|
||||
import {
|
||||
prepareSandboxChildExec,
|
||||
spawnSandboxChild,
|
||||
type SandboxChildOwner,
|
||||
} from "./sandbox-child.js";
|
||||
import type { HttpHeader, OpenClawExecServer } from "./types.js";
|
||||
|
||||
/** Maximum JSON-line size accepted from the streaming HTTP helper process. */
|
||||
@@ -111,38 +114,34 @@ async function runStreamingSandboxHttpRequest(
|
||||
params: SandboxHttpRequest,
|
||||
): Promise<JsonObject> {
|
||||
const backend = requireBackend(execServer);
|
||||
const remoteExec = prepareSandboxChildExec(backend, {});
|
||||
const execSpec = await backend.buildExecSpec({
|
||||
command: SANDBOX_HTTP_REQUEST_SCRIPT,
|
||||
workdir: execServer.sandbox.containerWorkdir,
|
||||
env: {},
|
||||
env: remoteExec.env,
|
||||
usePty: false,
|
||||
});
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
const [command, ...args] = execSpec.argv;
|
||||
if (!command) {
|
||||
throw new Error("OpenClaw sandbox HTTP exec spec did not provide a command.");
|
||||
}
|
||||
child = spawn(command, args, {
|
||||
env: execSpec.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
const lifecycle = { failed: false };
|
||||
const owner = await spawnSandboxChild({
|
||||
argv: execSpec.argv,
|
||||
env: execSpec.env,
|
||||
finalizeExec: backend.finalizeExec,
|
||||
finalizeToken: execSpec.finalizeToken,
|
||||
finalizeStatus: (outcome) =>
|
||||
lifecycle.failed || outcome.exitCode !== 0 ? "failed" : "completed",
|
||||
onFinalizeError: (error) => {
|
||||
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
|
||||
},
|
||||
owners: execServer.children,
|
||||
terminateRemote: remoteExec.terminate,
|
||||
});
|
||||
const child = owner.process;
|
||||
const abortOnSocketClose = () => {
|
||||
lifecycle.failed = true;
|
||||
void owner.terminate().catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex sandbox http/request cleanup failed", { error });
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await backend.finalizeExec?.({
|
||||
status: "failed",
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
token: execSpec.finalizeToken,
|
||||
});
|
||||
} catch (finalizeError) {
|
||||
embeddedAgentLog.warn("codex sandbox http/request finalize after start failure failed", {
|
||||
error: finalizeError,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const abortOnSocketClose = () => child.kill("SIGTERM");
|
||||
};
|
||||
socket.once("close", abortOnSocketClose);
|
||||
child.once("close", () => {
|
||||
socket.off("close", abortOnSocketClose);
|
||||
@@ -156,17 +155,17 @@ async function runStreamingSandboxHttpRequest(
|
||||
child.stdin.end(JSON.stringify(params));
|
||||
return await readStreamingSandboxHttpResponse({
|
||||
child,
|
||||
execSpec,
|
||||
finalizeExec: backend.finalizeExec,
|
||||
lifecycle,
|
||||
owner,
|
||||
requestId,
|
||||
socket,
|
||||
});
|
||||
}
|
||||
|
||||
function readStreamingSandboxHttpResponse(params: {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
execSpec: { finalizeToken?: unknown };
|
||||
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
|
||||
child: SandboxChildOwner["process"];
|
||||
lifecycle: { failed: boolean };
|
||||
owner: SandboxChildOwner;
|
||||
requestId: string;
|
||||
socket: WebSocket;
|
||||
}): Promise<JsonObject> {
|
||||
@@ -177,21 +176,14 @@ function readStreamingSandboxHttpResponse(params: {
|
||||
let lastBodySeq = 0;
|
||||
let stdoutBuffer = "";
|
||||
let stderr = "";
|
||||
const finalize = async (status: "completed" | "failed", exitCode: number | null) => {
|
||||
await params.finalizeExec?.({
|
||||
status,
|
||||
exitCode,
|
||||
timedOut: false,
|
||||
token: params.execSpec.finalizeToken,
|
||||
});
|
||||
};
|
||||
const fail = (message: string, exitCode: number | null) => {
|
||||
const fail = (message: string, _exitCode: number | null) => {
|
||||
if (failed) {
|
||||
return;
|
||||
}
|
||||
failed = true;
|
||||
void finalize("failed", exitCode).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
|
||||
params.lifecycle.failed = true;
|
||||
void params.owner.terminate().catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex sandbox http/request cleanup failed", { error });
|
||||
});
|
||||
if (headerResolved) {
|
||||
sendHttpBodyDelta(params.socket, {
|
||||
@@ -241,7 +233,6 @@ function readStreamingSandboxHttpResponse(params: {
|
||||
newline = stdoutBuffer.indexOf("\n");
|
||||
}
|
||||
if (stdoutBuffer.length > SANDBOX_HTTP_STREAM_LINE_MAX_CHARS) {
|
||||
params.child.kill("SIGKILL");
|
||||
fail(
|
||||
`sandbox http/request produced an unterminated stdout line longer than ${SANDBOX_HTTP_STREAM_LINE_MAX_CHARS} characters`,
|
||||
null,
|
||||
@@ -256,6 +247,7 @@ function readStreamingSandboxHttpResponse(params: {
|
||||
// ChildProcess error can precede close while the helper is still alive.
|
||||
// Keep its backend lease until close provides the terminal exit state.
|
||||
childFailure ??= error.message;
|
||||
params.lifecycle.failed = true;
|
||||
});
|
||||
params.child.once("close", (code) => {
|
||||
const exitCode = code ?? 1;
|
||||
@@ -267,10 +259,8 @@ function readStreamingSandboxHttpResponse(params: {
|
||||
return;
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
void finalize("completed", exitCode).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex sandbox http/request finalize failed", { error });
|
||||
});
|
||||
if (!headerResolved) {
|
||||
params.lifecycle.failed = true;
|
||||
reject(new Error("sandbox http/request exited before returning headers"));
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Manages subprocess lifecycle, streaming output buffers, stdin writes, and
|
||||
* termination for Codex sandbox exec-server process RPCs.
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sanitizeEnvVars } from "openclaw/plugin-sdk/sandbox";
|
||||
@@ -10,6 +9,7 @@ import type { WebSocket } from "ws";
|
||||
import type { JsonObject, JsonValue } from "../protocol.js";
|
||||
import { requireObject, requireString, requireStringArray } from "./json-rpc.js";
|
||||
import { resolveExecServerPath } from "./path-uri.js";
|
||||
import { prepareSandboxChildExec, spawnSandboxChild } from "./sandbox-child.js";
|
||||
import type { ManagedProcess, OpenClawExecServer, ProcessChunk } from "./types.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
@@ -45,9 +45,8 @@ export async function startProcess(
|
||||
failure: null,
|
||||
tty,
|
||||
pipeStdin,
|
||||
abortController: new AbortController(),
|
||||
terminationRequested: false,
|
||||
child: null,
|
||||
finalized: false,
|
||||
waiters: [],
|
||||
emitNotification: (method, notificationParams) => {
|
||||
if (socket.readyState === 1) {
|
||||
@@ -67,8 +66,10 @@ export async function startProcess(
|
||||
},
|
||||
};
|
||||
processes.set(processId, managed);
|
||||
const startPromise = runProcess(execServer, managed, { argv, cwd, env });
|
||||
managed.startPromise = startPromise;
|
||||
try {
|
||||
await runProcess(execServer, managed, { argv, cwd, env });
|
||||
await startPromise;
|
||||
} catch (error) {
|
||||
processes.delete(processId);
|
||||
managed.failure = coerceErrorMessage(error);
|
||||
@@ -77,6 +78,10 @@ export async function startProcess(
|
||||
managed.closed = true;
|
||||
notifyProcessWaiters(managed);
|
||||
throw error;
|
||||
} finally {
|
||||
if (managed.startPromise === startPromise) {
|
||||
managed.startPromise = undefined;
|
||||
}
|
||||
}
|
||||
return { processId };
|
||||
}
|
||||
@@ -91,43 +96,44 @@ async function runProcess(
|
||||
throw new Error("OpenClaw sandbox backend is unavailable.");
|
||||
}
|
||||
throwIfProcessStartCancelled(managed);
|
||||
const remoteExec = prepareSandboxChildExec(backend, params.env);
|
||||
const execSpec = await backend.buildExecSpec({
|
||||
command: shellCommandFromArgv(params.argv),
|
||||
workdir: params.cwd,
|
||||
env: params.env,
|
||||
env: remoteExec.env,
|
||||
// This bridge currently owns only pipe-backed child processes. Asking the
|
||||
// backend for a PTY can produce commands such as `docker exec -t`, which
|
||||
// require this process itself to own a real TTY.
|
||||
usePty: false,
|
||||
});
|
||||
managed.finalizeToken = execSpec.finalizeToken;
|
||||
managed.finalizeExec = backend.finalizeExec;
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
if (managed.abortController.signal.aborted) {
|
||||
throw new Error("process start cancelled");
|
||||
}
|
||||
const [command, ...args] = execSpec.argv;
|
||||
if (!command) {
|
||||
throw new Error("OpenClaw sandbox exec spec did not provide a command.");
|
||||
}
|
||||
child = spawn(command, args, {
|
||||
env: execSpec.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
if (managed.terminationRequested) {
|
||||
await backend.finalizeExec?.({
|
||||
status: "failed",
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
token: execSpec.finalizeToken,
|
||||
});
|
||||
} catch (error) {
|
||||
managed.failure = coerceErrorMessage(error);
|
||||
await finalizeProcess(managed).catch((finalizeError: unknown) => {
|
||||
embeddedAgentLog.warn("codex sandbox exec-server finalize after start failure failed", {
|
||||
processId: managed.processId,
|
||||
error: coerceErrorMessage(finalizeError),
|
||||
});
|
||||
});
|
||||
throw error;
|
||||
throw new Error("process start cancelled");
|
||||
}
|
||||
managed.child = child;
|
||||
const abortListener = () => child.kill("SIGTERM");
|
||||
managed.abortController.signal.addEventListener("abort", abortListener, { once: true });
|
||||
const owner = await spawnSandboxChild({
|
||||
argv: execSpec.argv,
|
||||
env: execSpec.env,
|
||||
finalizeExec: backend.finalizeExec,
|
||||
finalizeToken: execSpec.finalizeToken,
|
||||
finalizeStatus: () => (managed.failure ? "failed" : "completed"),
|
||||
onFinalizeError: (error) => {
|
||||
const message = coerceErrorMessage(error);
|
||||
managed.failure ??= message;
|
||||
embeddedAgentLog.warn("codex sandbox exec-server finalize failed", {
|
||||
processId: managed.processId,
|
||||
error: message,
|
||||
});
|
||||
},
|
||||
owners: execServer.children,
|
||||
terminateRemote: remoteExec.terminate,
|
||||
});
|
||||
managed.child = owner;
|
||||
const child = owner.process;
|
||||
child.stdout.on("data", (chunk: Buffer) =>
|
||||
appendProcessChunk(managed, managed.tty ? "pty" : "stdout", chunk),
|
||||
);
|
||||
@@ -139,7 +145,6 @@ async function runProcess(
|
||||
notifyProcessWaiters(managed);
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
managed.abortController.signal.removeEventListener("abort", abortListener);
|
||||
emitProcessClosed(managed, code ?? 1);
|
||||
});
|
||||
if (!managed.tty && !managed.pipeStdin) {
|
||||
@@ -148,7 +153,7 @@ async function runProcess(
|
||||
}
|
||||
|
||||
function throwIfProcessStartCancelled(managed: ManagedProcess): void {
|
||||
if (managed.abortController.signal.aborted) {
|
||||
if (managed.terminationRequested) {
|
||||
throw new Error("process start cancelled");
|
||||
}
|
||||
}
|
||||
@@ -210,34 +215,12 @@ function emitProcessClosed(managed: ManagedProcess, exitCode: number | null): vo
|
||||
seq: closeSeq,
|
||||
});
|
||||
}
|
||||
void finalizeProcess(managed).catch((error: unknown) => {
|
||||
const message = coerceErrorMessage(error);
|
||||
managed.failure ??= message;
|
||||
embeddedAgentLog.warn("codex sandbox exec-server finalize failed", {
|
||||
processId: managed.processId,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
// Closed processes stay briefly readable so clients that observe close before
|
||||
// their final poll can still drain exit/output state.
|
||||
managed.evictProcess();
|
||||
notifyProcessWaiters(managed);
|
||||
}
|
||||
|
||||
async function finalizeProcess(managed: ManagedProcess): Promise<void> {
|
||||
if (managed.finalized) {
|
||||
return;
|
||||
}
|
||||
managed.finalized = true;
|
||||
managed.child?.stdin.destroy();
|
||||
await managed.finalizeExec?.({
|
||||
status: managed.failure ? "failed" : "completed",
|
||||
exitCode: managed.exitCode,
|
||||
timedOut: false,
|
||||
token: managed.finalizeToken,
|
||||
});
|
||||
}
|
||||
|
||||
function limitProcessChunks(chunks: ProcessChunk[], maxBytes: number | undefined): ProcessChunk[] {
|
||||
if (!maxBytes) {
|
||||
return chunks;
|
||||
@@ -298,18 +281,22 @@ export function writeProcess(
|
||||
return { status: "unknownProcess" };
|
||||
}
|
||||
const chunk = Buffer.from(requireString(record.chunk, "chunk"), "base64");
|
||||
if ((!managed.tty && !managed.pipeStdin) || managed.closed || !managed.child?.stdin.writable) {
|
||||
if (
|
||||
(!managed.tty && !managed.pipeStdin) ||
|
||||
managed.closed ||
|
||||
!managed.child?.process.stdin.writable
|
||||
) {
|
||||
return { status: "stdinClosed" };
|
||||
}
|
||||
managed.child.stdin.write(chunk);
|
||||
managed.child.process.stdin.write(chunk);
|
||||
return { status: "accepted" };
|
||||
}
|
||||
|
||||
/** Requests process termination and reports whether it was running at call time. */
|
||||
export function terminateProcess(
|
||||
export async function terminateProcess(
|
||||
processes: Map<string, ManagedProcess>,
|
||||
params: JsonValue | undefined,
|
||||
): JsonObject {
|
||||
): Promise<JsonObject> {
|
||||
const record = requireObject(params, "process/terminate params");
|
||||
const processId = requireString(record.processId, "processId");
|
||||
const managed = processes.get(processId);
|
||||
@@ -317,9 +304,11 @@ export function terminateProcess(
|
||||
return { running: false };
|
||||
}
|
||||
const running = !managed.exited;
|
||||
managed.abortController.abort();
|
||||
managed.child?.kill("SIGTERM");
|
||||
if (running && !managed.child) {
|
||||
managed.terminationRequested = true;
|
||||
await managed.startPromise?.catch(() => undefined);
|
||||
if (managed.child) {
|
||||
await managed.child.terminate();
|
||||
} else if (running && !managed.closed) {
|
||||
emitProcessClosed(managed, null);
|
||||
}
|
||||
return { running };
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/** Owns one sandbox subprocess tree through close, reaping, and backend finalization. */
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { killProcessTree } from "openclaw/plugin-sdk/process-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
const SANDBOX_CHILD_TERM_GRACE_MS = 1_000;
|
||||
// Covers the post-TERM tree kill plus Windows taskkill completion before failure is reported.
|
||||
const SANDBOX_CHILD_REAP_TIMEOUT_MS = 4_500;
|
||||
const SANDBOX_EXEC_MARKER = "CODEX_SANDBOX_EXEC_ID";
|
||||
|
||||
type SandboxChildOutcome = { exitCode: number; signal: NodeJS.Signals | null };
|
||||
|
||||
export type SandboxChildOwner = {
|
||||
process: ChildProcessWithoutNullStreams;
|
||||
settled: Promise<SandboxChildOutcome>;
|
||||
terminate: () => Promise<SandboxChildOutcome>;
|
||||
};
|
||||
|
||||
export async function spawnSandboxChild(params: {
|
||||
argv: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
|
||||
finalizeToken?: unknown;
|
||||
finalizeStatus: (outcome: SandboxChildOutcome) => "completed" | "failed";
|
||||
onFinalizeError: (error: unknown) => void;
|
||||
owners: Set<SandboxChildOwner>;
|
||||
terminateRemote?: () => Promise<void>;
|
||||
}): Promise<SandboxChildOwner> {
|
||||
const [command, ...args] = params.argv;
|
||||
const finalize = async (status: "completed" | "failed", exitCode: number | null) =>
|
||||
await params.finalizeExec?.({
|
||||
status,
|
||||
exitCode,
|
||||
timedOut: false,
|
||||
token: params.finalizeToken,
|
||||
});
|
||||
if (!command) {
|
||||
await finalize("failed", null).catch(params.onFinalizeError);
|
||||
throw new Error("OpenClaw sandbox exec spec did not provide a command.");
|
||||
}
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
env: params.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
await finalize("failed", null).catch(params.onFinalizeError);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let outcome: SandboxChildOutcome | undefined;
|
||||
const closed = new Promise<SandboxChildOutcome>((resolve) => {
|
||||
child.once("close", (code, signal) => resolve((outcome = { exitCode: code ?? 1, signal })));
|
||||
});
|
||||
let finalizePromise: Promise<void> | undefined;
|
||||
let terminationCleanup: Promise<void> | undefined;
|
||||
let terminationError: Error | undefined;
|
||||
const settled = closed.then(async (result) => {
|
||||
await terminationCleanup;
|
||||
child.stdin.destroy();
|
||||
await (finalizePromise ??= finalize(params.finalizeStatus(result), result.exitCode));
|
||||
return result;
|
||||
});
|
||||
void settled.catch(params.onFinalizeError);
|
||||
|
||||
let terminationPromise: Promise<SandboxChildOutcome> | undefined;
|
||||
const owner: SandboxChildOwner = {
|
||||
process: child,
|
||||
settled,
|
||||
terminate: () =>
|
||||
(terminationPromise ??= (async () => {
|
||||
child.stdin.destroy();
|
||||
terminationCleanup = params.terminateRemote?.().catch((error: unknown) => {
|
||||
terminationError = error instanceof Error ? error : new Error(String(error));
|
||||
});
|
||||
await terminationCleanup;
|
||||
if (!outcome) {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid, {
|
||||
detached: process.platform !== "win32",
|
||||
graceMs: SANDBOX_CHILD_TERM_GRACE_MS,
|
||||
});
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
const reaped = await Promise.race([
|
||||
closed.then(() => true),
|
||||
delay(SANDBOX_CHILD_REAP_TIMEOUT_MS).then(() => false),
|
||||
]);
|
||||
if (!reaped) {
|
||||
throw new Error(
|
||||
`Sandbox child process tree ${child.pid ?? "unknown"} survived SIGKILL; tear down the sandbox environment and inspect the surviving process tree before retrying.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const result = await settled;
|
||||
if (terminationError) {
|
||||
throw terminationError;
|
||||
}
|
||||
return result;
|
||||
})()),
|
||||
};
|
||||
params.owners.add(owner);
|
||||
void settled.then(
|
||||
() => params.owners.delete(owner),
|
||||
() => params.owners.delete(owner),
|
||||
);
|
||||
return owner;
|
||||
}
|
||||
|
||||
export function prepareSandboxChildExec(
|
||||
backend: NonNullable<SandboxContext["backend"]>,
|
||||
env: Record<string, string>,
|
||||
): { env: Record<string, string>; terminate: () => Promise<void> } {
|
||||
const marker = randomUUID();
|
||||
return {
|
||||
env: { ...env, [SANDBOX_EXEC_MARKER]: marker },
|
||||
terminate: async () => {
|
||||
const result = await backend.runShellCommand({
|
||||
script: SANDBOX_REMOTE_TERMINATE_SCRIPT,
|
||||
args: [`${SANDBOX_EXEC_MARKER}=${marker}`],
|
||||
allowFailure: true,
|
||||
signal: AbortSignal.timeout(SANDBOX_CHILD_REAP_TIMEOUT_MS),
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
const detail =
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim();
|
||||
throw new Error(
|
||||
detail ||
|
||||
`Sandbox process tree cleanup failed with code ${result.code}; tear down the sandbox environment and inspect surviving processes before retrying.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_REMOTE_TERMINATE_SCRIPT = String.raw`
|
||||
find_owned_pids() {
|
||||
for env_file in /proc/[0-9]*/environ; do
|
||||
if [ -r "$env_file" ] && tr '\0' '\n' < "$env_file" 2>/dev/null | grep -Fqx "$1"; then
|
||||
basename "$(dirname "$env_file")"
|
||||
fi
|
||||
done
|
||||
}
|
||||
owned="$(find_owned_pids "$1")"
|
||||
[ -z "$owned" ] || kill -TERM $owned 2>/dev/null || true
|
||||
sleep 1
|
||||
owned="$(find_owned_pids "$1")"
|
||||
[ -z "$owned" ] || kill -KILL $owned 2>/dev/null || true
|
||||
sleep 1
|
||||
owned="$(find_owned_pids "$1")"
|
||||
[ -z "$owned" ] || { echo "Sandbox process IDs survived SIGKILL: $owned" >&2; exit 1; }
|
||||
`.trim();
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
* Shared protocol and runtime state types for the Codex sandbox exec-server
|
||||
* WebSocket bridge.
|
||||
*/
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/sandbox";
|
||||
import type { WebSocketServer } from "ws";
|
||||
import type { JsonObject, JsonValue } from "../protocol.js";
|
||||
import type { SandboxChildOwner } from "./sandbox-child.js";
|
||||
|
||||
/** Minimal JSON-RPC request shape accepted by the sandbox exec-server. */
|
||||
export type JsonRpcRequest = {
|
||||
@@ -70,11 +70,9 @@ export type ManagedProcess = {
|
||||
failure: string | null;
|
||||
tty: boolean;
|
||||
pipeStdin: boolean;
|
||||
abortController: AbortController;
|
||||
child: ChildProcessWithoutNullStreams | null;
|
||||
finalizeToken?: unknown;
|
||||
finalizeExec?: NonNullable<SandboxContext["backend"]>["finalizeExec"];
|
||||
finalized: boolean;
|
||||
terminationRequested: boolean;
|
||||
child: SandboxChildOwner | null;
|
||||
startPromise?: Promise<void>;
|
||||
evictionTimer?: ReturnType<typeof setTimeout>;
|
||||
waiters: Array<() => void>;
|
||||
emitNotification: (method: string, params: JsonObject) => void;
|
||||
@@ -90,4 +88,6 @@ export type OpenClawExecServer = {
|
||||
url: string;
|
||||
sandbox: SandboxContext;
|
||||
server: WebSocketServer;
|
||||
children: Set<SandboxChildOwner>;
|
||||
cleanupTasks: Set<Promise<void>>;
|
||||
};
|
||||
|
||||
@@ -12,4 +12,5 @@ export {
|
||||
} from "../process/exec.js";
|
||||
export { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js";
|
||||
export type { OomScoreAdjustedSpawn, OomWrapOptions } from "../process/linux-oom-score.js";
|
||||
export { killProcessTree } from "../process/kill-tree.js";
|
||||
export { isPidAlive } from "../shared/pid-alive.js";
|
||||
|
||||
Reference in New Issue
Block a user