fix(tooling): enforce Linux process-tree memory limits (#160024)

Add opt-in aggregate Linux process-tree memory containment to the managed tooling runner. Own the invocation-specific scope through verified cleanup before releasing its resource claim; preserve uncapped caller behavior.

Validated real-kernel OOM, descendant cleanup, signals, cancellation and packaged launcher behavior; focused lifecycle/preload tests and exact-head CI/security gates passed. Related: #160010.
This commit is contained in:
Vincent Koc
2026-09-28 11:58:07 +08:00
committed by GitHub
parent ab416e262c
commit b9cd492ac6
16 changed files with 1119 additions and 25 deletions
+111 -20
View File
@@ -8,6 +8,7 @@ import type {
StdioOptions,
} from "node:child_process";
import { constants as osConstants, tmpdir } from "node:os";
import path from "node:path";
import { Writable, type Readable } from "node:stream";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../windows-cmd-helpers.mjs";
import type { ManagedWindowsJob } from "./managed-windows-job.mts";
@@ -74,7 +75,9 @@ type ManagedCommandOptions = {
comSpec?: string;
};
type RunManagedCommandOptions = ManagedCommandOptions & {
export type RunManagedCommandOptions = ManagedCommandOptions & {
memoryLimitBytes?: number;
onMemoryScope?: (unit: string) => void;
timeoutMs?: number;
timeoutKillGraceMs?: number;
signalKillGraceMs?: number;
@@ -450,22 +453,108 @@ export async function waitForManagedProcessGroupExit(
}
/** Run a child command while forwarding termination signals to its process group. */
export async function runManagedCommand({
stdio = "inherit",
platform = process.platform,
timeoutMs,
timeoutKillGraceMs,
signalKillGraceMs,
timeoutForceKillOnLeaderExit = false,
requireProcessTreeExit = false,
runTaskkill = spawnSync,
onReady,
signal,
abortKillGraceMs,
cleanupDrainTimeoutMs,
onSignal,
...commandOptions
}: RunManagedCommandOptions) {
export async function runManagedCommand(options: RunManagedCommandOptions): Promise<number> {
const { memoryLimitBytes } = options;
if (memoryLimitBytes !== undefined) {
if (!Number.isSafeInteger(memoryLimitBytes) || memoryLimitBytes <= 0) {
throw new Error("Managed command memory limit must be a positive integer");
}
const platform = options.platform ?? process.platform;
if (platform === "linux") {
if (
Array.isArray(options.stdio) &&
(options.stdio.length > 3 || options.stdio.includes("ipc"))
) {
throw new Error(
"Linux memory-limited commands do not support IPC or extra stdio descriptors",
);
}
// Preserve spawn's input snapshot while the Linux containment module loads.
const command = {
...options,
args: options.args?.slice(),
cwd: path.resolve(options.cwd ?? process.cwd()),
env: { ...(options.env ?? process.env) },
stdio: Array.isArray(options.stdio) ? [...options.stdio] : options.stdio,
};
const { runLinuxMemoryCommand } = await import("./managed-memory.mts");
// The cgroup owner can prove extinction after a process-group cleanup failure.
// Its resource claim therefore outlives the inner runner's weaker observation.
const env = command.env;
const releaseClaim = findVitestResourceOwner(
env.TMPDIR || env.TMP || env.TEMP || tmpdir(),
)?.claim();
let joined = true;
let leafActive = false;
let receivedSignal: NodeJS.Signals | undefined;
// The leaf owns delivery and grace. Keep the outer owner alive after it
// exits, while the cgroup still joins detached members and releases claims.
const rememberSignal = (received: NodeJS.Signals) => {
receivedSignal ??= received;
if (!leafActive) {
command.onSignal?.(received);
}
};
installSignalHandlers();
managedChildren.add(rememberSignal);
try {
const status = await runLinuxMemoryCommand(command, async (scopedCommand) => {
leafActive = true;
try {
return await runManagedCommandInner(scopedCommand, false);
} finally {
leafActive = false;
}
});
if (receivedSignal) {
return signalExitCode(receivedSignal);
}
if (command.signal?.aborted) {
throw Object.assign(new Error("Managed command aborted"), { code: "ABORT_ERR" });
}
return status;
} catch (error) {
joined = !hasUnjoinedWork(error);
throw error;
} finally {
try {
if (joined) {
releaseClaim?.();
}
} finally {
managedChildren.delete(rememberSignal);
removeSignalHandlersIfIdle();
}
}
}
throw new Error(
"Semantic checks require verified kernel memory containment. Run this command through Crabbox or a memory-limited Linux VM; native containment is not qualified on this platform.",
);
}
return await runManagedCommandInner(options);
}
async function runManagedCommandInner(
{
stdio = "inherit",
platform = process.platform,
memoryLimitBytes: _memoryLimitBytes,
onMemoryScope: _onMemoryScope,
timeoutMs,
timeoutKillGraceMs,
signalKillGraceMs,
timeoutForceKillOnLeaderExit = false,
requireProcessTreeExit = false,
runTaskkill = spawnSync,
onReady,
signal,
abortKillGraceMs,
cleanupDrainTimeoutMs,
onSignal,
...commandOptions
}: RunManagedCommandOptions,
claimResources = true,
) {
if (platform === "win32" && requireProcessTreeExit) {
throw Object.assign(
new Error("Strict managed process-tree verification is not supported on Windows"),
@@ -506,9 +595,11 @@ export async function runManagedCommand({
const loading = loadManagedChildSpawner(platform);
const spawnManagedChild = typeof loading === "function" ? loading : await loading;
signal?.throwIfAborted();
let releaseClaim = findVitestResourceOwner(
commandEnv.TMPDIR || commandEnv.TMP || commandEnv.TEMP || tmpdir(),
)?.claim();
let releaseClaim = claimResources
? findVitestResourceOwner(
commandEnv.TMPDIR || commandEnv.TMP || commandEnv.TEMP || tmpdir(),
)?.claim()
: undefined;
const releaseOwnership = () => {
releaseClaim?.();
releaseClaim = undefined;
+21
View File
@@ -0,0 +1,21 @@
import { extname } from "node:path";
import { fileURLToPath } from "node:url";
export const managedMemoryEntrypoint = {
currentModuleUrl: import.meta.url,
sourceWorkerName: "managed-memory-launcher",
sourceExtension: ".mts",
distWorkerPath: "tooling/managed-memory-launcher.js",
} as const;
/** Resolve the standalone launcher in source checkouts and packaged tooling. */
export function resolveManagedMemoryEntrypointUrl(): URL {
const current = new URL(import.meta.url);
const distIndex = current.pathname.lastIndexOf("/dist/");
return distIndex < 0
? new URL(`./managed-memory-launcher${extname(fileURLToPath(current))}`, current)
: new URL(
current.pathname.slice(0, distIndex + 6) + managedMemoryEntrypoint.distWorkerPath,
current,
);
}
+63
View File
@@ -0,0 +1,63 @@
import { spawn } from "node:child_process";
import { signalExitCode } from "./managed-child-process.mts";
import { hasLinuxMemoryContainment } from "./process-memory.mts";
const [rawLimit, scope, shell, bin, ...args] = process.argv.slice(2);
const maxBytes = Number(rawLimit);
if (
!scope ||
!bin ||
(shell !== "true" && shell !== "false") ||
!Number.isSafeInteger(maxBytes) ||
maxBytes <= 0 ||
!hasLinuxMemoryContainment(maxBytes, {}, scope)
) {
console.error(
"[memory] The owned cgroup has no verified memory/swap limit; semantic command was not started. Use a cgroup-v2 host with a user systemd manager or a bounded Crabbox.",
);
process.exitCode = 75;
} else {
const env: NodeJS.ProcessEnv = {
...process.env,
NODE_OPTIONS: process.env.OPENCLAW_MANAGED_NODE_OPTIONS ?? "",
};
delete env.OPENCLAW_MANAGED_NODE_OPTIONS;
// The outer cgroup owner owns descendants and resource receipts, including OOM.
// Keep both processes in the outer group: it owns signal delivery and grace.
const child = spawn(bin, args, { env, stdio: "inherit", shell: shell === "true" });
let received: NodeJS.Signals | undefined;
let exitSignal: NodeJS.Signals | undefined;
const remember = (signal: NodeJS.Signals) => {
received ??= signal;
};
const handlers = (["SIGINT", "SIGTERM", "SIGHUP"] as const).map(
(signal) => [signal, () => remember(signal)] as const,
);
for (const [signal, handler] of handlers) {
process.on(signal, handler);
}
process.exitCode = await new Promise<number>((resolve) => {
child.once("error", (error) => {
console.error(error);
resolve(75);
});
child.once("exit", (status, signal) => {
exitSignal = received ?? signal ?? undefined;
resolve(exitSignal ? signalExitCode(exitSignal) : (status ?? 75));
});
});
for (const [signal, handler] of handlers) {
process.off(signal, handler);
}
// The outer owner distinguishes a signal from numeric 143 to let surviving
// descendants drain gracefully. Removing a signal listener restores its default
// disposition in libuv, including Node's special SIGPIPE/SIGUSR1 dispositions.
if (exitSignal) {
if (exitSignal !== "SIGKILL" && exitSignal !== "SIGSTOP") {
const reset = () => {};
process.on(exitSignal, reset);
process.off(exitSignal, reset);
}
process.kill(process.pid, exitSignal);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { hasUnjoinedWork, type RunManagedCommandOptions } from "./managed-child-process.mts";
import { resolveManagedMemoryEntrypointUrl } from "./managed-memory-entrypoint.mts";
/** systemd owns the cgroup; the managed child owner still owns signals and output. */
export async function runLinuxMemoryCommand(
options: RunManagedCommandOptions,
run: (options: RunManagedCommandOptions) => Promise<number>,
) {
const { memoryLimitBytes, onMemoryScope, ...command } = options;
// Scope names belong to this invocation. A caller-selected name can race
// creation and let a failed contender stop another command during cleanup.
const unit = "openclaw-check-" + randomUUID() + ".scope";
options.signal?.throwIfAborted();
const control = (args: string[]) =>
spawnSync("systemctl", ["--user", ...args, unit], {
encoding: "utf8",
timeout: 5_000,
killSignal: "SIGKILL",
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
// Qualification precedes creation: unavailable backends must not strand admission.
const initial = control(["show", "--property=LoadState"]);
if (initial.error || !initial.stdout?.includes("LoadState=not-found")) {
throw new Error(
"[memory] A cgroup-v2 systemd user manager is required. Use a bounded Crabbox worker.",
);
}
onMemoryScope?.(unit);
options.signal?.throwIfAborted();
const env = { ...(options.env ?? process.env) };
// The trusted launcher verifies kernel limits before restoring workload preloads.
env.OPENCLAW_MANAGED_NODE_OPTIONS = env.NODE_OPTIONS ?? "";
delete env.NODE_OPTIONS;
const cleanupScope = async (failure: unknown) => {
// Inner cleanup has finished its grace period. A stop-client timeout does not
// escalate systemd's longer stop timer, so kill any remaining owned descendants.
control(["kill", "--kill-whom=all", "--signal=SIGKILL"]);
control(["stop"]);
const deadline = Date.now() + 5_000;
let empty: boolean;
do {
const state = control([
"show",
"--property=LoadState",
"--property=ActiveState",
"--property=ControlGroup",
]);
const fields = Object.fromEntries(
(state.stdout ?? "")
.trim()
.split("\n")
.map((line) => line.split("=")),
);
empty = !state.error && fields.LoadState === "not-found";
if (!state.error && fields.ControlGroup?.endsWith("/" + unit)) {
try {
empty = /^populated 0$/mu.test(
fs.readFileSync(
path.join("/sys/fs/cgroup", fields.ControlGroup, "cgroup.events"),
"utf8",
),
);
} catch (error) {
empty = Boolean(
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT" &&
fields.ActiveState === "inactive",
);
}
}
if (empty) {
break;
}
await delay(50);
} while (Date.now() < deadline);
// A failed scope can still contain descendants after a SIGKILL timeout.
// Only kernel extinction or manager-confirmed removal releases admission.
if (!empty) {
throw Object.assign(
new Error("Memory scope cleanup could not be verified: " + unit, { cause: failure }),
{
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
},
);
}
if (hasUnjoinedWork(failure)) {
// The cgroup includes detached descendants that escaped the inner process group.
// Preserve failure, but replace its superseded cleanup receipt after extinction.
throw Object.assign(new Error(failure instanceof Error ? failure.message : String(failure)), {
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "terminated",
});
}
};
let failure: unknown;
try {
return await run({
...command,
// Cgroup ownership starts cleanup at launcher exit, even when a detached
// descendant still holds output open and the caller supplied no deadline.
requireProcessTreeExit: true,
bin: "systemd-run",
shell: false,
env,
args: [
"--user",
"--scope",
"--collect",
"--quiet",
"--expand-environment=no",
"--unit=" + unit,
"--property=MemoryMax=" + memoryLimitBytes,
"--property=MemorySwapMax=0",
"--property=OOMPolicy=kill",
"--",
process.execPath,
fileURLToPath(resolveManagedMemoryEntrypointUrl()),
String(memoryLimitBytes),
unit,
String(options.shell ?? false),
options.bin,
...(options.args ?? []),
],
});
} catch (error) {
failure = error;
throw error;
} finally {
await cleanupScope(failure);
}
}
+41
View File
@@ -522,3 +522,44 @@ export function readProcessMemoryCapacity(params: MemoryLimitParams) {
: Math.min(cgroupMemory.limitBytes, physicalLimitBytes);
return { ...cgroupMemory, capacityBytes, limitBytes, availableBytes: hostAvailableBytes };
}
/** Verify actual kernel containment, rather than accepting an environment or manager claim. */
export function hasLinuxMemoryContainment(
maxBytes: number,
params: MemoryLimitParams = {},
scope?: string,
) {
if ((params.platform ?? process.platform) !== "linux") {
return false;
}
const resolved = resolveCgroupMemoryLimitPaths(params);
if (
!resolved.sawObservedV2Mapping ||
resolved.cgroupRecordReadFailed ||
resolved.sawUnresolvedCgroupLimit
) {
return false;
}
const files = params.fs ?? fs;
return resolved.paths.some((file) => {
if (path.basename(file) !== "memory.max") {
return false;
}
if (scope && path.basename(path.dirname(file)) !== scope) {
return false;
}
try {
const limit = parseCgroupMemoryLimitBytes(files.readFileSync(file, "utf8"));
const directory = path.dirname(file);
return (
limit !== null &&
limit > 0 &&
limit <= maxBytes &&
files.readFileSync(path.join(directory, "memory.swap.max"), "utf8").trim() === "0" &&
files.readFileSync(path.join(directory, "memory.oom.group"), "utf8").trim() === "1"
);
} catch {
return false;
}
});
}
@@ -1,5 +1,6 @@
import { fileURLToPath } from "node:url";
import { runtimeProcessEntrypoints } from "../../src/infra/runtime-process-entrypoints.ts";
import { managedMemoryEntrypoint } from "./managed-memory-entrypoint.mts";
import { managedWindowsJobEntrypoint } from "./managed-windows-job-entrypoint.mts";
export function createRuntimeProcessBuildEntries(
@@ -26,6 +27,7 @@ export function createRuntimeProcessBuildEntries(
export const runtimeProcessCoreEntrypoints = [
...Object.values(runtimeProcessEntrypoints),
managedWindowsJobEntrypoint,
managedMemoryEntrypoint,
];
export const runtimeProcessCoreBuildEntries = createRuntimeProcessBuildEntries(
runtimeProcessCoreEntrypoints,
@@ -33,6 +35,7 @@ export const runtimeProcessCoreBuildEntries = createRuntimeProcessBuildEntries(
// Keep small helper processes out of the shared runtime bundle.
export const standaloneRuntimeProcessBuildEntries = createRuntimeProcessBuildEntries([
managedMemoryEntrypoint,
runtimeProcessEntrypoints.sqliteReadOnly,
runtimeProcessEntrypoints.sqliteSourceRevision,
runtimeProcessEntrypoints.stateRead,
+9 -1
View File
@@ -34,7 +34,15 @@ describe("jsdom native API boundary", () => {
await environment.teardown(globalThis);
}
`,
path.resolve("ui/package.json"),
path.join(
path.dirname(
process
.getBuiltinModule("module")
.createRequire(path.resolve("ui/package.json"))
.resolve("vitest/package.json"),
),
"dist/workers/forks.js",
),
],
{ encoding: "utf8" },
);
@@ -47,6 +47,7 @@ function expectedHarnessSparseCheckoutArgs(linux: boolean) {
"/scripts/lib/pnpm-lockfile-documents.mjs",
"/scripts/ios-screenshot-evidence.mjs",
"/scripts/lib/direct-run.mjs",
"/scripts/ci-static-step.sh",
...(linux
? [
"/scripts/lib/release-upgrade-baseline.mjs",
@@ -391,6 +392,7 @@ it.concurrent.each([
const evidenceScripts = {
"scripts/ios-screenshot-evidence.mjs": "workflow evidence script\n",
"scripts/lib/direct-run.mjs": "workflow direct-run script\n",
"scripts/ci-static-step.sh": "workflow static-step script\n",
};
const nodeSetupScripts = {
"scripts/lib/pnpm-lockfile-documents.mjs": readFileSync(
@@ -472,6 +472,11 @@ function runCheckShardFixture(options: {
mkdirSync(fakeBin);
if (typeCheck) {
mkdirSync(path.join(root, "scripts"));
mkdirSync(path.join(root, ".ci-harness/scripts"), { recursive: true });
copyFileSync(
new URL("../../scripts/ci-static-step.sh", import.meta.url),
path.join(root, ".ci-harness/scripts/ci-static-step.sh"),
);
writeFileSync(
path.join(root, "scripts/run-tsgo-core-test-shards.mts"),
options.types?.stripeSupport === false ? "// legacy runner\n" : "// --stripe\n",
+3
View File
@@ -53,12 +53,15 @@ export function waitForFile(file) {
"lib/tsx-cli-shim.mjs",
"lib/local-check-runtime.mts",
"lib/check-limits.mts",
"lib/ci-static-check-evidence.mjs",
"lib/direct-run.mjs",
"lib/dist-artifact-ownership.mts",
"lib/dist-artifact-lock.mts",
"lib/record-shared.mjs",
"lib/failed-trailer.mts",
"lib/managed-child-process.mts",
"lib/managed-memory.mts",
"lib/managed-memory-entrypoint.mts",
"lib/vitest-resource-ownership.mts",
"lib/windows-taskkill.mjs",
"lib/repo-root.mjs",
@@ -0,0 +1,208 @@
import { spawnSync } from "node:child_process";
import { expect, it } from "vitest";
import {
hasUnjoinedWork,
runManagedCommand,
signalExitCode,
} from "../../scripts/lib/managed-child-process.mts";
import { hasSemanticTestBackend } from "./native-boundary-fixture.js";
const available = process.platform === "linux" && hasSemanticTestBackend();
it.runIf(available)(
"contains aggregate native allocations and joins the whole cgroup after OOM",
async ({ signal }) => {
let memoryScope = "";
const allocate =
"const a=[];for(let i=0;i<16;i++)a.push(Buffer.alloc(8*1024**2,1));setInterval(()=>{},1000)";
const code = await runManagedCommand({
bin: process.execPath,
args: [
"-e",
[
"const {spawn}=require('node:child_process');",
"for(let i=0;i<2;i++)spawn(process.execPath,['-e'," +
JSON.stringify(allocate) +
"],{stdio:'inherit'});",
"setInterval(()=>{},1000);",
].join("\n"),
],
memoryLimitBytes: 256 * 1024 ** 2,
onMemoryScope(unit) {
memoryScope = unit;
},
timeoutMs: 15_000,
requireProcessTreeExit: true,
signal,
});
expect(code).toBe(137);
const state = spawnSync("systemctl", ["--user", "show", "--property=LoadState", memoryScope], {
encoding: "utf8",
timeout: 5_000,
});
expect(state.stdout).toContain("LoadState=not-found");
},
25_000,
);
it.runIf(available).for([false, true])(
"returns the workload result through a verified bounded launcher (shell: %s)",
{ timeout: 20_000 },
async (shell, { signal }) => {
let output = "";
const code = await runManagedCommand({
bin: shell ? "printf 'bounded\\n'; exit 7" : process.execPath,
args: shell ? [] : ["-e", "process.stdin.pipe(process.stdout);process.exitCode=7;"],
shell,
memoryLimitBytes: 256 * 1024 ** 2,
timeoutMs: 10_000,
requireProcessTreeExit: true,
signal,
stdio: shell ? ["ignore", "pipe", "pipe"] : "pipe",
onReady(child) {
child.stdout!.on("data", (chunk) => {
output += String(chunk);
});
child.stdin?.end("bounded\n");
},
});
expect(code).toBe(7);
expect(output).toBe("bounded\n");
},
);
it.runIf(available).for(["SIGPIPE", "SIGUSR1"] as const)(
"preserves native %s exit status through Node's special signal disposition",
{ timeout: 15_000 },
async (signal, { signal: abortSignal }) => {
let exitSignal: NodeJS.Signals | null | undefined;
const code = await runManagedCommand({
bin: "/bin/sh",
args: ["-c", `kill -${signal.slice(3)} $$`],
memoryLimitBytes: 256 * 1024 ** 2,
timeoutMs: 10_000,
signal: abortSignal,
stdio: "ignore",
onReady(child) {
child.once("exit", (_code, received) => {
exitSignal = received;
});
},
});
expect(code).toBe(signalExitCode(signal));
expect(exitSignal).toBe(signal);
},
);
it.runIf(available)(
"keeps one cancellation signal and the caller's longer cleanup grace",
async ({ signal }) => {
const abort = new AbortController();
let output = "";
const result = runManagedCommand({
bin: process.execPath,
args: [
"-e",
[
"let signals=0;process.on('SIGTERM',()=>{",
"if(++signals>1)process.exit(9);process.stdout.write('term\\n');",
// This must exceed the removed launcher's independent five-second timer.
"setTimeout(()=>process.stdout.write('drained\\n',()=>process.exit(0)),5500)});",
"setInterval(()=>{},1000);console.log('ready');",
].join("\n"),
],
memoryLimitBytes: 256 * 1024 ** 2,
timeoutMs: 15_000,
abortKillGraceMs: 7_000,
signal: AbortSignal.any([signal, abort.signal]),
stdio: ["ignore", "pipe", "pipe"],
onReady(child) {
child.stdout!.on("data", (chunk) => {
output += String(chunk);
if (output.includes("ready\n")) {
abort.abort();
}
});
},
});
await expect(result).rejects.toMatchObject({ code: "ABORT_ERR" });
expect(output).toBe("ready\nterm\ndrained\n");
},
20_000,
);
it.runIf(available)(
"joins a detached pipe holder with default cleanup options and no deadline",
async ({ signal }) => {
let memoryScope = "";
let failure: unknown;
let status: number | undefined;
try {
status = await runManagedCommand({
bin: process.execPath,
args: [
"-e",
[
"const leaf=\"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready');process.disconnect()\";",
"const child=require('node:child_process').spawn(process.execPath,['-e',leaf],{detached:true,stdio:['ignore','inherit','inherit','ipc']});",
"child.once('message',()=>process.exit(0));",
].join("\n"),
],
memoryLimitBytes: 256 * 1024 ** 2,
onMemoryScope(unit) {
memoryScope = unit;
},
signal,
});
} catch (error) {
failure = error;
}
expect(failure !== undefined || (status !== undefined && status !== 0)).toBe(true);
expect(hasUnjoinedWork(failure)).toBe(false);
const state = spawnSync("systemctl", ["--user", "show", "--property=LoadState", memoryScope], {
encoding: "utf8",
timeout: 5_000,
});
expect(state.stdout).toContain("LoadState=not-found");
},
30_000,
);
it.runIf(available)(
"preserves a workload signal exit so surviving descendants drain gracefully",
async ({ signal }) => {
let output = "";
let exitSignal: NodeJS.Signals | null | undefined;
const leaf =
"process.on('SIGTERM',()=>process.stdout.write('drained\\n',()=>process.exit(0)));setInterval(()=>{},1000);process.send('ready');process.disconnect()";
const code = await runManagedCommand({
bin: process.execPath,
args: [
"-e",
[
"const child=require('node:child_process').spawn(process.execPath,['-e'," +
JSON.stringify(leaf) +
"],{stdio:['ignore','inherit','inherit','ipc']});",
"child.once('message',()=>process.kill(process.pid,'SIGTERM'));",
].join("\n"),
],
memoryLimitBytes: 256 * 1024 ** 2,
timeoutMs: 10_000,
requireProcessTreeExit: true,
signal,
stdio: ["ignore", "pipe", "pipe"],
onReady(child) {
child.stdout!.on("data", (chunk) => {
output += String(chunk);
});
child.once("exit", (_code, received) => {
exitSignal = received;
});
},
});
expect(code).toBe(143);
expect(exitSignal).toBe("SIGTERM");
expect(output).toBe("drained\n");
},
20_000,
);
+389
View File
@@ -0,0 +1,389 @@
import type { StdioOptions } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
import { runLinuxMemoryCommand } from "../../scripts/lib/managed-memory.mts";
import { hasLinuxMemoryContainment } from "../../scripts/lib/process-memory.mts";
import { createDeferred } from "../helpers/promise.js";
const mocks = vi.hoisted(() => ({
control: vi.fn(),
uuid: vi.fn(() => "abcd"),
resourceOwner: vi.fn(),
}));
vi.mock("node:child_process", () => ({ spawnSync: mocks.control }));
vi.mock("node:crypto", () => ({ randomUUID: mocks.uuid }));
vi.mock("../../scripts/lib/vitest-resource-ownership.mts", () => ({
findVitestResourceOwner: mocks.resourceOwner,
}));
afterEach(() => {
vi.restoreAllMocks();
mocks.resourceOwner.mockReset();
});
const command = {
bin: "fixture",
memoryLimitBytes: 256 * 1024 ** 2,
};
const memoryScope = "openclaw-check-abcd.scope";
const response = (stdout: string) => ({ stdout, stderr: "", status: 0 });
it("removes signal ownership even when resource receipt release fails", async () => {
const memory = await import("../../scripts/lib/managed-memory.mts");
vi.spyOn(memory, "runLinuxMemoryCommand").mockResolvedValue(0);
const error = new Error("receipt release failed");
mocks.resourceOwner.mockReturnValue({
claim: () => () => {
throw error;
},
});
const previousListeners = new Set(process.listeners("SIGTERM"));
await expect(runManagedCommand({ ...command, platform: "linux" })).rejects.toBe(error);
expect(new Set(process.listeners("SIGTERM"))).toEqual(previousListeners);
});
it.for([
{ kind: "abort", uncertain: false },
{ kind: "signal", uncertain: false },
{ kind: "abort", uncertain: true },
{ kind: "signal", uncertain: true },
])(
"owns $kind cancellation until cgroup cleanup settles (uncertain=$uncertain)",
async (params) => {
const enteredCleanup = createDeferred<void>();
const cleanup = createDeferred<number>();
const memory = await import("../../scripts/lib/managed-memory.mts");
vi.spyOn(memory, "runLinuxMemoryCommand").mockImplementation(async () => {
enteredCleanup.resolve();
return await cleanup.promise;
});
const release = vi.fn();
mocks.resourceOwner.mockReturnValue({ claim: () => release });
const abort = new AbortController();
const previousListeners = new Set(process.listeners("SIGTERM"));
const onSignal = vi.fn();
const result = runManagedCommand({
...command,
platform: "linux",
signal: abort.signal,
onSignal,
});
let settled = false;
void result.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await enteredCleanup.promise;
if (params.kind === "abort") {
abort.abort();
} else {
// Invoke only this command's listener; never signal the shared test process.
const handler = process
.listeners("SIGTERM")
.find((listener) => !previousListeners.has(listener));
expect(handler).toBeTypeOf("function");
handler!("SIGTERM");
expect(onSignal).toHaveBeenCalledExactlyOnceWith("SIGTERM");
}
await Promise.resolve();
expect(settled).toBe(false);
expect(release).not.toHaveBeenCalled();
if (params.uncertain) {
const failure = Object.assign(new Error("scope still populated"), {
processTreeState: "live",
});
cleanup.reject(failure);
await expect(result).rejects.toBe(failure);
expect(release).not.toHaveBeenCalled();
} else {
cleanup.resolve(0);
if (params.kind === "abort") {
await expect(result).rejects.toMatchObject({ code: "ABORT_ERR" });
} else {
await expect(result).resolves.toBe(143);
}
expect(release).toHaveBeenCalledOnce();
}
expect(new Set(process.listeners("SIGTERM"))).toEqual(previousListeners);
},
);
it("snapshots Linux command inputs before loading containment", async () => {
const memory = await import("../../scripts/lib/managed-memory.mts");
const run = vi.spyOn(memory, "runLinuxMemoryCommand").mockResolvedValue(0);
const args = ["original"];
const env = { TMPDIR: process.cwd(), VALUE: "original" };
const stdio: StdioOptions = ["ignore", "pipe", "pipe"];
const cwd = process.cwd();
const result = runManagedCommand({ ...command, args, env, stdio, cwd: ".", platform: "linux" });
vi.spyOn(process, "cwd").mockReturnValue(path.dirname(cwd));
args[0] = "mutated";
env.VALUE = "mutated";
stdio[1] = "ignore";
await expect(result).resolves.toBe(0);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
args: ["original"],
env: { ...env, VALUE: "original" },
cwd,
stdio: ["ignore", "pipe", "pipe"],
}),
expect.any(Function),
);
});
it.for<StdioOptions>(["inherit", "pipe", "ignore", [0, 1, 2], [null, "pipe"]])(
"preserves standard stdio %j",
async (stdio) => {
const memory = await import("../../scripts/lib/managed-memory.mts");
const run = vi.spyOn(memory, "runLinuxMemoryCommand").mockResolvedValue(0);
await runManagedCommand({ ...command, stdio, platform: "linux" });
expect(run).toHaveBeenCalledWith(expect.objectContaining({ stdio }), expect.any(Function));
},
);
it.for<StdioOptions>([
["ignore", "pipe", "ipc"],
["ignore", "pipe", "pipe", "pipe"],
])("rejects unsupported stdio %j before entering containment", async (stdio) => {
const memory = await import("../../scripts/lib/managed-memory.mts");
const run = vi.spyOn(memory, "runLinuxMemoryCommand");
await expect(runManagedCommand({ ...command, stdio, platform: "linux" })).rejects.toThrow(
"do not support IPC or extra stdio descriptors",
);
expect(run).not.toHaveBeenCalled();
});
it.each([undefined, 1_001])(
"keeps wall deadline ownership in the managed runner (%s)",
async (timeoutMs) => {
mocks.control.mockReturnValue(response("LoadState=not-found\n"));
const run = vi.fn(async (_options: Parameters<typeof runLinuxMemoryCommand>[0]) => 0);
await runLinuxMemoryCommand({ ...command, timeoutMs }, run);
expect(
run.mock.calls[0]?.[0]?.args?.filter((arg) => arg.startsWith("--property=RuntimeMaxSec=")),
).toEqual([]);
expect(run).toHaveBeenCalledWith(expect.objectContaining({ requireProcessTreeExit: true }));
},
);
it("generates separate cleanup identities for concurrent invocations", async () => {
mocks.control.mockReset().mockReturnValue(response("LoadState=not-found\n"));
mocks.uuid.mockReturnValueOnce("aaaa").mockReturnValueOnce("bbbb");
const scopes: string[] = [];
const run = vi.fn(async () => 0);
await Promise.all(
[0, 1].map(() =>
runLinuxMemoryCommand({ ...command, onMemoryScope: (unit) => scopes.push(unit) }, run),
),
);
expect(scopes).toEqual(["openclaw-check-aaaa.scope", "openclaw-check-bbbb.scope"]);
expect(run.mock.calls).toHaveLength(2);
for (const unit of scopes) {
expect(mocks.control).toHaveBeenCalledWith(
"systemctl",
["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit],
expect.any(Object),
);
}
});
it("refuses an existing generated scope without launching or stopping it", async () => {
mocks.control.mockReset().mockReturnValue(response("LoadState=loaded\n"));
const run = vi.fn();
await expect(runLinuxMemoryCommand(command, run)).rejects.toThrow("user manager");
expect(run).not.toHaveBeenCalled();
expect(mocks.control).toHaveBeenCalledTimes(1);
});
it.each(["darwin", "win32"] as const)(
"refuses an unqualified %s cap before starting a workload",
async (platform) => {
mocks.control.mockClear();
await expect(runManagedCommand({ ...command, platform })).rejects.toThrow("not qualified");
expect(mocks.control).not.toHaveBeenCalled();
},
);
it("forces remaining scope members before waiting for systemd cleanup", async () => {
mocks.control.mockReset().mockReturnValue(response("LoadState=not-found\n"));
await runLinuxMemoryCommand(command, async () => 0);
expect(mocks.control.mock.calls.map((call) => call[1])).toEqual([
["--user", "show", "--property=LoadState", memoryScope],
["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", memoryScope],
["--user", "stop", memoryScope],
[
"--user",
"show",
"--property=LoadState",
"--property=ActiveState",
"--property=ControlGroup",
memoryScope,
],
]);
});
it("does not start work when the systemd user manager is unavailable", async () => {
mocks.control.mockReturnValue({ error: new Error("no user bus"), stdout: "", status: 1 });
const run = vi.fn();
await expect(runLinuxMemoryCommand(command, run)).rejects.toThrow("user manager");
expect(run).not.toHaveBeenCalled();
});
it("preserves a pre-spawn failure without claiming an unjoined scope", async () => {
mocks.control.mockReturnValue(response("LoadState=not-found\n"));
const error = Object.assign(new Error("systemd-run missing"), { code: "ENOENT" });
await expect(
runLinuxMemoryCommand(command, async () => {
throw error;
}),
).rejects.toBe(error);
});
it.each([
["1", false],
["0", true],
])(
"requires kernel extinction before releasing a failed scope (populated=%s)",
async (populated, empty) => {
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => (now += 6_000));
mocks.control
.mockReset()
.mockReturnValue(
response(
"LoadState=loaded\nActiveState=failed\nControlGroup=/user.slice/openclaw-check-abcd.scope\n",
),
)
.mockReturnValueOnce(response("LoadState=not-found\n"));
vi.spyOn(fs, "readFileSync").mockReturnValue("populated " + populated + "\n");
const run = runLinuxMemoryCommand(command, async () => 137);
if (empty) {
await expect(run).resolves.toBe(137);
} else {
await expect(run).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
});
}
},
);
it.each([undefined, "ENOENT"])(
"preserves failure %s when descendant cleanup is uncertain",
async (code) => {
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => (now += 6_000));
mocks.control
.mockReset()
.mockReturnValue(response("LoadState=loaded\nActiveState=failed\n"))
.mockReturnValueOnce(response("LoadState=not-found\n"));
const original = Object.assign(new Error("workload failed"), { code });
await expect(
runLinuxMemoryCommand(command, async () => {
throw original;
}),
).rejects.toMatchObject({ cause: original });
},
);
it("passes literal arguments and restores preloads only inside the bounded launcher", async () => {
mocks.control.mockReturnValue(response("LoadState=not-found\n"));
const run = vi.fn(async () => 0);
await expect(
runLinuxMemoryCommand(
{ ...command, args: ["$LITERAL"], env: { NODE_OPTIONS: "--require=workload" } },
run,
),
).resolves.toBe(0);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining([
"--expand-environment=no",
"--property=MemoryMax=268435456",
"--property=MemorySwapMax=0",
"--property=OOMPolicy=kill",
"$LITERAL",
]),
env: { OPENCLAW_MANAGED_NODE_OPTIONS: "--require=workload" },
}),
);
});
it.each([undefined, null])(
"retains admission when a cleanup probe has no output (%s)",
async (stdout) => {
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => (now += 6_000));
mocks.control
.mockReset()
.mockReturnValue({
error: Object.assign(new Error("spawn failed"), { code: "ENOMEM" }),
stdout,
})
.mockReturnValueOnce(response("LoadState=not-found\n"));
await expect(runLinuxMemoryCommand(command, async () => 0)).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
});
},
);
it.each([
{ scope: "openclaw-check-abcd.scope", swap: "0", group: "1", expected: true },
{ scope: "other.scope", swap: "0", group: "1", expected: false },
{ scope: "openclaw-check-abcd.scope", swap: "max", group: "1", expected: false },
{ scope: "openclaw-check-abcd.scope", swap: "0", group: "0", expected: false },
])(
"qualifies the owned kernel cgroup only: $scope/$swap/$group",
({ scope, swap, group, expected }) => {
const files: Record<string, string> = {
"/proc/self/cgroup": "0::/user.slice/" + scope + "\n",
"/proc/self/mountinfo": "29 23 0:26 / /sys/fs/cgroup rw - cgroup2 cgroup rw\n",
["/sys/fs/cgroup/user.slice/" + scope + "/memory.max"]: "268435456",
["/sys/fs/cgroup/user.slice/" + scope + "/memory.swap.max"]: swap,
["/sys/fs/cgroup/user.slice/" + scope + "/memory.oom.group"]: group,
"/sys/fs/cgroup/user.slice/memory.max": "268435456",
"/sys/fs/cgroup/user.slice/memory.swap.max": "0",
"/sys/fs/cgroup/user.slice/memory.oom.group": "1",
};
expect(
hasLinuxMemoryContainment(
command.memoryLimitBytes,
{
platform: "linux",
fs: {
readFileSync(file) {
if (!(file in files)) {
throw Object.assign(new Error(file), { code: "ENOENT" });
}
return files[file]!;
},
},
},
memoryScope,
),
).toBe(expected);
},
);
it("records cgroup extinction after an inner process-group cleanup failure", async () => {
mocks.control.mockReturnValue(response("LoadState=not-found\n"));
const original = Object.assign(new Error("detached descendant held output"), {
processTreeState: "live",
});
const result = runLinuxMemoryCommand(command, async () => {
throw original;
});
await expect(result).rejects.toMatchObject({
message: original.message,
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "terminated",
});
await expect(result).rejects.not.toHaveProperty("cause");
});
+17
View File
@@ -5,6 +5,23 @@ import path from "node:path";
const require = createRequire(import.meta.url);
/** Availability only; integration assertions still verify the actual kernel scope. */
export function hasSemanticTestBackend(): boolean {
if (process.platform !== "linux") return false;
try {
return (
fs
.readFileSync("/sys/fs/cgroup/cgroup.controllers", "utf8")
.split(/\s+/u)
.includes("memory") &&
spawnSync("systemctl", ["--user", "show", "--property=Version"], { timeout: 5_000 })
.status === 0
);
} catch {
return false;
}
}
/** Native receipts and default libraries must belong to the fixture's own install. */
export function materializeNativeCompiler(rootDir: string) {
const root = fs.realpathSync.native(rootDir);
@@ -246,6 +246,7 @@ describe("tsdown config", () => {
requireStandaloneRuntimeGraph("agents/harness/native-hook-relay-client.worker"),
requireStandaloneRuntimeGraph("process/spawn-broker/worker"),
requireStandaloneRuntimeGraph("state/openclaw-state-lease-heartbeat.worker"),
requireStandaloneRuntimeGraph("tooling/managed-memory-launcher"),
]);
for (const config of configs) {
+96
View File
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it } from "vitest";
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { createFixtureLifetime } from "../helpers/fixture-lifetime.js";
const lifetime = createFixtureLifetime();
afterEach(() => lifetime.cleanup());
const preload = new URL("../vitest/vitest.jsdom-preload.mts", import.meta.url).href;
async function run(root: string, args: string[], signal: AbortSignal) {
let stdout = "";
let stderr = "";
const code = await lifetime.track(
runManagedCommand({
bin: resolveTestNodeExecPath(),
args: ["--no-warnings", `--import=${preload}`, ...args],
cwd: root,
signal,
timeoutMs: 10_000,
requireProcessTreeExit: true,
stdio: ["ignore", "pipe", "pipe"],
onReady(child) {
child.stdout!.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr!.on("data", (chunk) => {
stderr += String(chunk);
});
},
}),
);
return { code, stdout, stderr };
}
it("leaves inherited fork and thread preloads inert outside Vitest workers", ({ signal }) =>
lifetime.run(async () => {
const root = lifetime.createTempDir("openclaw-preload-descendants-");
const child = path.join(root, "child.mjs");
fs.writeFileSync(
child,
`
import { parentPort } from 'node:worker_threads';
const flags = process.execArgv.includes('--no-warnings');
if (parentPort) parentPort.postMessage({ kind: 'thread', flags });
else process.send({ kind: 'fork', flags }, () => process.disconnect());
`,
);
const parent = path.join(root, "parent.mjs");
fs.writeFileSync(
parent,
`
import { fork } from 'node:child_process';
import { Worker } from 'node:worker_threads';
const wait = child => new Promise((resolve, reject) => {
let message;
child.once('message', value => { message = value; });
child.once('error', reject);
child.once('exit', code => code === 0 ? resolve(message) : reject(new Error('exit ' + code)));
});
const forked = fork(${JSON.stringify(child)}, [], { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] });
const thread = new Worker(new URL(${JSON.stringify(pathToFileURL(child).href)}));
console.log(JSON.stringify(await Promise.all([wait(forked), wait(thread)])));
`,
);
const result = await run(root, [parent], signal);
expect(result.code, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual([
{ kind: "fork", flags: true },
{ kind: "thread", flags: true },
]);
}));
it("does not require an entrypoint for ordinary eval commands", ({ signal }) =>
lifetime.run(async () => {
const result = await run(
lifetime.createTempDir("openclaw-preload-eval-"),
["--eval", "process.stdout.write('ok')"],
signal,
);
expect(result).toEqual({ code: 0, stdout: "ok", stderr: "" });
}));
it("keeps runtime resolution failures fatal for recognized Vitest workers", ({ signal }) =>
lifetime.run(async () => {
const root = lifetime.createTempDir("openclaw-preload-invalid-worker-");
const entrypoint = path.join(root, "vitest/dist/workers/forks.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, "throw new Error('worker must not start');");
const result = await run(root, [entrypoint], signal);
expect(result.code).not.toBe(0);
expect(result.stderr).toContain("Cannot find module 'vitest/runtime'");
expect(result.stderr).not.toContain("worker must not start");
}));
+9 -4
View File
@@ -1,6 +1,11 @@
import { installJsdomEnvironmentAdapter } from "../jsdom-compat.mts";
// Match the worker's Vitest instance, including package-local pnpm peer graphs.
const require = process.getBuiltinModule("module").createRequire(process.argv[1]!);
const { builtinEnvironments }: typeof import("vitest/runtime") = require("vitest/runtime");
installJsdomEnvironmentAdapter(builtinEnvironments.jsdom);
// Vitest 5 starts these four packaged workers. Descendants inherit execArgv,
// but ordinary forks/threads must not load a test runtime or alter their IPC.
const entrypoint = process.argv[1]?.replaceAll("\\", "/");
if (/\/vitest\/dist\/workers\/(?:forks|threads|vmForks|vmThreads)\.js$/u.test(entrypoint ?? "")) {
// Match the active worker's instance, including package-local pnpm peer graphs.
const require = process.getBuiltinModule("module").createRequire(process.argv[1]!);
const { builtinEnvironments }: typeof import("vitest/runtime") = require("vitest/runtime");
installJsdomEnvironmentAdapter(builtinEnvironments.jsdom);
}