From cc9dcd3d69e36e5d2768fd765941751e27bcb807 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 05:10:30 +0100 Subject: [PATCH] fix(gateway): prefer linux child OOM victims Raise eligible Linux child processes own oom_score_adj from a child-side /bin/sh exec shim so cgroup memory pressure prefers transient workers over the long-lived gateway. Cover supervisor children, PTY shells, MCP stdio servers, and OpenClaw-launched browser processes through the shared process runtime seam. Harden the wrapper for distroless images, shell startup env, per-child and process-level opt-outs, dash-compatible exec, and leading-dash command names. Document Linux verification and OOM behavior. Fixes #70404. Co-authored-by: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com> --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/platforms/linux.md | 37 +++++ docs/vps.md | 3 + extensions/browser/src/browser/chrome.ts | 8 +- src/agents/mcp-stdio-transport.test.ts | 23 ++- src/agents/mcp-stdio-transport.ts | 17 ++- src/plugin-sdk/process-runtime.ts | 2 + src/process/linux-oom-score.test.ts | 105 +++++++++++++ src/process/linux-oom-score.ts | 143 ++++++++++++++++++ src/process/supervisor/adapters/child.test.ts | 54 ++++++- src/process/supervisor/adapters/child.ts | 9 +- src/process/supervisor/adapters/pty.test.ts | 63 +++++++- src/process/supervisor/adapters/pty.ts | 7 +- 14 files changed, 451 insertions(+), 25 deletions(-) create mode 100644 src/process/linux-oom-score.test.ts create mode 100644 src/process/linux-oom-score.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a76e13c8b6..c3ed545cddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris. - Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT. - Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana. +- Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny `/bin/sh` shim that raises the child's own `oom_score_adj` on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`. Fixes #70404. (#70419) Thanks @neeravmakwana. - Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.:`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314. - Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies. - Codex harness: ignore dynamic tool descriptions when deciding whether to reuse a native app-server thread while still fingerprinting tool schemas, so channel-specific copy changes no longer reset otherwise compatible Codex conversations. (#69976) Thanks @chen-zhang-cs-code. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 01df532b278..64d14208266 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e10f01ce10a381ecb098b805cee95b7278d16de42e02c7873f54448eb2b6c5cc plugin-sdk-api-baseline.json -918b646ff2e0849c4feba5ef930a08187a7bdad3a2d35ba4e1dd456fe3ea2cea plugin-sdk-api-baseline.jsonl +6297ca54fecbf277f3ed2e76410cc79aef95cf7dd887ab2383858a2132f81777 plugin-sdk-api-baseline.json +aa3343fda656a0034f9dd5ec7e28fcf45d49b15c1ed64329673ac1629285730c plugin-sdk-api-baseline.jsonl diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 9394e757303..cbc9a9255b3 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -3,6 +3,7 @@ summary: "Linux support + companion app status" read_when: - Looking for Linux companion app status - Planning platform coverage or contributions + - Debugging Linux OOM kills or exit 137 on a VPS or container title: "Linux App" --- @@ -98,3 +99,39 @@ Enable it: ``` systemctl --user enable --now openclaw-gateway[-].service ``` + +## Memory pressure and OOM kills + +On Linux, the kernel chooses an OOM victim when a host, VM, or container cgroup +runs out of memory. The Gateway can be a poor victim because it owns long-lived +sessions and channel connections. OpenClaw therefore biases transient child +processes to be killed before the Gateway when possible. + +For eligible Linux child spawns, OpenClaw starts the child through a short +`/bin/sh` wrapper that raises the child's own `oom_score_adj` to `1000`, then +`exec`s the real command. This is an unprivileged operation because the child is +only increasing its own OOM kill likelihood. + +Covered child process surfaces include: + +- supervisor-managed command children, +- PTY shell children, +- MCP stdio server children, +- OpenClaw-launched browser/Chrome processes. + +The wrapper is Linux-only and is skipped when `/bin/sh` is unavailable. It is +also skipped if the child env sets `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`, `false`, +`no`, or `off`. + +To verify a child process: + +```bash +cat /proc//oom_score_adj +``` + +Expected value for covered children is `1000`. The Gateway process should keep +its normal score, usually `0`. + +This does not replace normal memory tuning. If a VPS or container repeatedly +kills children, increase the memory limit, reduce concurrency, or add stronger +resource controls such as systemd `MemoryMax=` or container-level memory limits. diff --git a/docs/vps.md b/docs/vps.md index 0f4b33d4d22..7421131ea71 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -114,3 +114,6 @@ If you deliberately installed a system unit instead, edit How `Restart=` policies help automated recovery: [systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery). + +For Linux OOM behavior, child process victim selection, and `exit 137` +diagnostics, see [Linux memory pressure and OOM kills](/platforms/linux#memory-pressure-and-oom-kills). diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 7b82ea89059..67c638afb50 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -2,6 +2,7 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams, spawn } from "n import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { prepareOomScoreAdjustedSpawn } from "openclaw/plugin-sdk/process-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { ensurePortAvailable } from "../infra/ports.js"; @@ -290,13 +291,16 @@ export async function launchOpenClawChrome( // environments (e.g. Docker), while keeping stderr piped for diagnostics. // Cast to ChildProcessWithoutNullStreams so callers can use .stderr safely; // the tuple overload resolution varies across @types/node versions. - return spawn(exe.path, args, { - stdio: ["ignore", "ignore", "pipe"], + const preparedSpawn = prepareOomScoreAdjustedSpawn(exe.path, args, { env: { ...process.env, // Reduce accidental sharing with the user's env. HOME: os.homedir(), }, + }); + return spawn(preparedSpawn.command, preparedSpawn.args, { + stdio: ["ignore", "ignore", "pipe"], + env: preparedSpawn.env, }) as unknown as ChildProcessWithoutNullStreams; }; diff --git a/src/agents/mcp-stdio-transport.test.ts b/src/agents/mcp-stdio-transport.test.ts index fb1dd5e1e6c..2eebd52ba5f 100644 --- a/src/agents/mcp-stdio-transport.test.ts +++ b/src/agents/mcp-stdio-transport.test.ts @@ -45,9 +45,25 @@ describe("OpenClawStdioClientTransport", () => { child.emit("spawn"); await started; - expect(spawnMock).toHaveBeenCalledWith( - "npx", - ["-y", "example-mcp"], + const [command, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { env?: NodeJS.ProcessEnv }, + ]; + if (process.platform === "linux") { + expect(command).toBe("/bin/sh"); + expect(args).toEqual([ + "-c", + 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"', + "npx", + "-y", + "example-mcp", + ]); + } else { + expect(command).toBe("npx"); + expect(args).toEqual(["-y", "example-mcp"]); + } + expect(options).toEqual( expect.objectContaining({ cwd: "/tmp/example", detached: process.platform !== "win32", @@ -55,6 +71,7 @@ describe("OpenClawStdioClientTransport", () => { stdio: ["pipe", "pipe", "pipe"], }), ); + expect(options.env).toEqual(expect.objectContaining({ EXAMPLE: "1" })); expect(transport.pid).toBe(4321); expect(transport.stderr).toBeInstanceOf(PassThrough); }); diff --git a/src/agents/mcp-stdio-transport.ts b/src/agents/mcp-stdio-transport.ts index 00049eb73a6..5ff242bd28b 100644 --- a/src/agents/mcp-stdio-transport.ts +++ b/src/agents/mcp-stdio-transport.ts @@ -6,6 +6,7 @@ import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/s import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { killProcessTree } from "../process/kill-tree.js"; +import { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js"; export type OpenClawStdioServerParameters = { command: string; @@ -46,13 +47,19 @@ export class OpenClawStdioClientTransport implements Transport { } await new Promise((resolve, reject) => { - const child = spawn(this.serverParams.command, this.serverParams.args ?? [], { + const baseEnv = { + ...getDefaultEnvironment(), + ...this.serverParams.env, + }; + const preparedSpawn = prepareOomScoreAdjustedSpawn( + this.serverParams.command, + this.serverParams.args ?? [], + { env: baseEnv }, + ); + const child = spawn(preparedSpawn.command, preparedSpawn.args, { cwd: this.serverParams.cwd, detached: process.platform !== "win32", - env: { - ...getDefaultEnvironment(), - ...this.serverParams.env, - }, + env: preparedSpawn.env, shell: false, stdio: ["pipe", "pipe", this.serverParams.stderr ?? "inherit"], windowsHide: process.platform === "win32", diff --git a/src/plugin-sdk/process-runtime.ts b/src/plugin-sdk/process-runtime.ts index 826ed2d1197..7298de04d10 100644 --- a/src/plugin-sdk/process-runtime.ts +++ b/src/plugin-sdk/process-runtime.ts @@ -1,3 +1,5 @@ // Public process helpers for plugins that spawn or probe local commands. export * from "../process/exec.js"; +export { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js"; +export type { OomScoreAdjustedSpawn, OomWrapOptions } from "../process/linux-oom-score.js"; diff --git a/src/process/linux-oom-score.test.ts b/src/process/linux-oom-score.test.ts new file mode 100644 index 00000000000..038b2b893e7 --- /dev/null +++ b/src/process/linux-oom-score.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + hardenedEnvForChildOomWrap, + prepareOomScoreAdjustedSpawn, + wrapArgvForChildOomScoreRaise, +} from "./linux-oom-score.js"; + +const argv = ["/usr/bin/node", "--max-old-space-size=256", "run.js", "arg with spaces"]; +const wrapScript = 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"'; +const linux = { platform: "linux", env: {}, shellAvailable: () => true } as const; +const linuxNoShell = { platform: "linux", env: {}, shellAvailable: () => false } as const; + +describe("wrapArgvForChildOomScoreRaise", () => { + it("wraps argv on linux with default env", () => { + const result = wrapArgvForChildOomScoreRaise(argv, linux); + expect(result.slice(0, 3)).toEqual(["/bin/sh", "-c", wrapScript]); + expect(result.slice(3)).toEqual(argv); + }); + + it("returns argv unchanged on non-linux platforms", () => { + for (const platform of ["darwin", "win32", "freebsd"] as const) { + expect( + wrapArgvForChildOomScoreRaise(argv, { platform, env: {}, shellAvailable: () => true }), + ).toEqual(argv); + } + }); + + it("respects the OPENCLAW_CHILD_OOM_SCORE_ADJ opt-out", () => { + for (const value of ["0", "false", "FALSE", "no", "off"]) { + expect( + wrapArgvForChildOomScoreRaise(argv, { + ...linux, + env: { OPENCLAW_CHILD_OOM_SCORE_ADJ: value }, + }), + ).toEqual(argv); + } + }); + + it("skips wrap when /bin/sh is unavailable (distroless/scratch)", () => { + expect(wrapArgvForChildOomScoreRaise(argv, linuxNoShell)).toEqual(argv); + }); + + it("does not double-wrap already-wrapped argv", () => { + const once = wrapArgvForChildOomScoreRaise(argv, linux); + const twice = wrapArgvForChildOomScoreRaise(once, linux); + expect(twice).toEqual(once); + }); + + it("returns empty argv unchanged", () => { + expect(wrapArgvForChildOomScoreRaise([], linux)).toEqual([]); + }); + + it("skips wrap for command names that exec could parse as options", () => { + expect(wrapArgvForChildOomScoreRaise(["-p", "node"], linux)).toEqual(["-p", "node"]); + }); +}); + +describe("prepareOomScoreAdjustedSpawn", () => { + it("returns command, args, and hardened env when wrap applies", () => { + const result = prepareOomScoreAdjustedSpawn("/usr/bin/node", ["run.js"], { + ...linux, + env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv", ENV: "/tmp/env", CDPATH: "/tmp" }, + }); + expect(result).toEqual({ + command: "/bin/sh", + args: ["-c", wrapScript, "/usr/bin/node", "run.js"], + env: { PATH: "/usr/bin" }, + wrapped: true, + }); + }); + + it("preserves the spawn shape when wrap does not apply", () => { + const env = { PATH: "/usr/bin" }; + expect( + prepareOomScoreAdjustedSpawn("/usr/bin/node", ["run.js"], { + platform: "darwin", + env, + shellAvailable: () => true, + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["run.js"], + env, + wrapped: false, + }); + }); +}); + +describe("hardenedEnvForChildOomWrap", () => { + const tainted = { PATH: "/usr/bin", BASH_ENV: "/tmp/evil.sh", ENV: "/tmp/evil", CDPATH: "/tmp" }; + + it("strips shell-init keys when wrap applies", () => { + expect(hardenedEnvForChildOomWrap(tainted, linux)).toEqual({ PATH: "/usr/bin" }); + }); + + it("preserves baseEnv (including undefined) when wrap does not apply", () => { + expect(hardenedEnvForChildOomWrap(tainted, linuxNoShell)).toBe(tainted); + expect( + hardenedEnvForChildOomWrap(undefined, { platform: "darwin", shellAvailable: () => true }), + ).toBeUndefined(); + expect( + hardenedEnvForChildOomWrap(tainted, { ...linux, env: { OPENCLAW_CHILD_OOM_SCORE_ADJ: "0" } }), + ).toBe(tainted); + }); +}); diff --git a/src/process/linux-oom-score.ts b/src/process/linux-oom-score.ts new file mode 100644 index 00000000000..351c576a4c1 --- /dev/null +++ b/src/process/linux-oom-score.ts @@ -0,0 +1,143 @@ +import fs from "node:fs"; + +/** + * On Linux, children spawned by a long-lived parent (e.g., the gateway) inherit + * the parent's `oom_score_adj`. Under cgroup memory pressure the kernel tends + * to pick the largest-RSS process as the OOM victim, which is usually the + * gateway rather than its transient workers. See issue #70404. + * + * Since Linux 2.6.20 any unprivileged process may voluntarily *raise* its own + * `oom_score_adj` without `CAP_SYS_RESOURCE`. We exploit that by wrapping the + * child argv in a tiny `/bin/sh` shim that raises the score in the post-fork + * child and then `exec`s the real command, so there is no extra long-lived + * shell process and no change to the final process identity. + * + * Opt out per-process by setting `OPENCLAW_CHILD_OOM_SCORE_ADJ=0` (also + * accepts `false`/`no`/`off`). Callers may also provide the key via + * `params.env` for per-child overrides. + */ + +export const CHILD_OOM_SCORE_ADJ_ENV_KEY = "OPENCLAW_CHILD_OOM_SCORE_ADJ"; +const OOM_SCORE_WRAP_SHELL = "/bin/sh"; +const OOM_SCORE_WRAP_SCRIPT = 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"'; + +// Env keys that can cause /bin/sh (especially bash invoked as sh) to source +// caller-influenced startup files before the final `exec`. Stripped when we +// wrap so the shim can't become an env-controlled code-exec primitive. +const SHELL_INIT_ENV_KEYS = ["BASH_ENV", "ENV", "CDPATH"] as const; + +function isDisabled(value: string | undefined): boolean { + switch (value?.trim().toLowerCase()) { + case "0": + case "false": + case "no": + case "off": + return true; + default: + return false; + } +} + +let cachedShellAvailable: boolean | null = null; +function defaultShellAvailable(): boolean { + if (cachedShellAvailable !== null) { + return cachedShellAvailable; + } + try { + cachedShellAvailable = fs.statSync(OOM_SCORE_WRAP_SHELL).isFile(); + } catch { + cachedShellAvailable = false; + } + return cachedShellAvailable; +} + +export type OomWrapOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + shellAvailable?: () => boolean; +}; + +export type OomScoreAdjustedSpawn = { + command: string; + args: string[]; + env: NodeJS.ProcessEnv | undefined; + wrapped: boolean; +}; + +function shouldWrapChildForOomScore(options: OomWrapOptions | undefined): boolean { + const platform = options?.platform ?? process.platform; + if (platform !== "linux") { + return false; + } + const env = options?.env ?? process.env; + if (isDisabled(env[CHILD_OOM_SCORE_ADJ_ENV_KEY])) { + return false; + } + return (options?.shellAvailable ?? defaultShellAvailable)(); +} + +function isWrapped(command: string, args: readonly string[]): boolean { + return command === OOM_SCORE_WRAP_SHELL && args[0] === "-c" && args[1] === OOM_SCORE_WRAP_SCRIPT; +} + +function canUseShellExecCommand(command: string): boolean { + // POSIX sh implementations such as dash do not support `exec --`. A command + // starting with "-" could be parsed as an exec option, so keep that rare + // shape on the original direct-spawn path instead of wrapping it. + return !command.startsWith("-"); +} + +function hardenShellEnv(baseEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...(baseEnv ?? process.env) }; + for (const key of SHELL_INIT_ENV_KEYS) { + delete next[key]; + } + return next; +} + +export function prepareOomScoreAdjustedSpawn( + command: string, + args: readonly string[] = [], + options?: OomWrapOptions, +): OomScoreAdjustedSpawn { + const copy = [...args]; + if (!command || !canUseShellExecCommand(command) || !shouldWrapChildForOomScore(options)) { + return { command, args: copy, env: options?.env, wrapped: false }; + } + if (isWrapped(command, copy)) { + return { command, args: copy, env: hardenShellEnv(options?.env), wrapped: true }; + } + return { + command: OOM_SCORE_WRAP_SHELL, + args: ["-c", OOM_SCORE_WRAP_SCRIPT, command, ...copy], + env: hardenShellEnv(options?.env), + wrapped: true, + }; +} + +export function wrapArgvForChildOomScoreRaise( + argv: readonly string[], + options?: OomWrapOptions, +): string[] { + const copy = [...argv]; + if (copy.length === 0) { + return copy; + } + const spawn = prepareOomScoreAdjustedSpawn(copy[0] ?? "", copy.slice(1), options); + return [spawn.command, ...spawn.args]; +} + +/** + * Returns `baseEnv` with shell-init keys stripped when argv will be wrapped. + * Unchanged (including `undefined`) when no wrap applies, so non-Linux and + * opted-out paths keep exact inherited-env semantics. + */ +export function hardenedEnvForChildOomWrap( + baseEnv: NodeJS.ProcessEnv | undefined, + options?: OomWrapOptions, +): NodeJS.ProcessEnv | undefined { + if (!shouldWrapChildForOomScore(options)) { + return baseEnv; + } + return hardenShellEnv(baseEnv); +} diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 038cb214ffe..a3007b4da32 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -215,18 +215,70 @@ describe("createChildAdapter", () => { expect(spawnArgs.fallbacks ?? []).toEqual([]); }); - it("keeps inherited env when no override env is provided", async () => { + it("keeps inherited env when no override env is provided on non-Linux", async () => { + setPlatform("darwin"); + await createAdapterHarness({ pid: 3333, argv: ["node", "-e", "process.exit(0)"], }); const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { + argv?: string[]; options?: { env?: NodeJS.ProcessEnv }; }; + expect(spawnArgs.argv).toEqual(["node", "-e", "process.exit(0)"]); expect(spawnArgs.options?.env).toBeUndefined(); }); + it("wraps Linux child spawns and strips shell-init env", async () => { + const originalBashEnv = process.env.BASH_ENV; + const originalEnv = process.env.ENV; + const originalCdpath = process.env.CDPATH; + setPlatform("linux"); + process.env.BASH_ENV = "/tmp/bashenv"; + process.env.ENV = "/tmp/env"; + process.env.CDPATH = "/tmp"; + try { + await createAdapterHarness({ + pid: 3334, + argv: ["/usr/bin/node", "-e", "process.exit(0)"], + }); + } finally { + if (originalBashEnv === undefined) { + delete process.env.BASH_ENV; + } else { + process.env.BASH_ENV = originalBashEnv; + } + if (originalEnv === undefined) { + delete process.env.ENV; + } else { + process.env.ENV = originalEnv; + } + if (originalCdpath === undefined) { + delete process.env.CDPATH; + } else { + process.env.CDPATH = originalCdpath; + } + } + + const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { + argv?: string[]; + options?: { env?: NodeJS.ProcessEnv }; + }; + expect(spawnArgs.argv?.slice(0, 4)).toEqual([ + "/bin/sh", + "-c", + 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"', + "/usr/bin/node", + ]); + expect(spawnArgs.argv?.slice(4)).toEqual(["-e", "process.exit(0)"]); + expect(spawnArgs.options?.env).toBeDefined(); + expect(spawnArgs.options?.env?.BASH_ENV).toBeUndefined(); + expect(spawnArgs.options?.env?.ENV).toBeUndefined(); + expect(spawnArgs.options?.env?.CDPATH).toBeUndefined(); + }); + it("passes explicit env overrides as strings", async () => { await createAdapterHarness({ pid: 4444, diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 8ee964092b6..0d71f30e8de 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -1,5 +1,6 @@ import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process"; import { killProcessTree } from "../../kill-tree.js"; +import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js"; import { spawnWithFallback } from "../../spawn-utils.js"; import { resolveWindowsCommandShim } from "../../windows-command.js"; import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js"; @@ -31,6 +32,10 @@ export async function createChildAdapter(params: { }): Promise { const resolvedArgv = [...params.argv]; resolvedArgv[0] = resolveCommand(resolvedArgv[0] ?? ""); + const baseEnv = params.env ? toStringEnv(params.env) : undefined; + const preparedSpawn = prepareOomScoreAdjustedSpawn(resolvedArgv[0] ?? "", resolvedArgv.slice(1), { + env: baseEnv, + }); const stdinMode = params.stdinMode ?? (params.input !== undefined ? "pipe-closed" : "inherit"); @@ -41,7 +46,7 @@ export async function createChildAdapter(params: { const options: SpawnOptions = { cwd: params.cwd, - env: params.env ? toStringEnv(params.env) : undefined, + env: preparedSpawn.env, stdio: ["pipe", "pipe", "pipe"], detached: useDetached, windowsHide: true, @@ -54,7 +59,7 @@ export async function createChildAdapter(params: { } const spawned = await spawnWithFallback({ - argv: resolvedArgv, + argv: [preparedSpawn.command, ...preparedSpawn.args], options, fallbacks: useDetached ? [ diff --git a/src/process/supervisor/adapters/pty.test.ts b/src/process/supervisor/adapters/pty.test.ts index 021bd89a717..b9107284ad5 100644 --- a/src/process/supervisor/adapters/pty.test.ts +++ b/src/process/supervisor/adapters/pty.test.ts @@ -40,6 +40,14 @@ function expectSpawnEnv() { return spawnOptions?.env; } +function expectSpawnCommand() { + return spawnMock.mock.calls[0]?.[0] as string | undefined; +} + +function expectSpawnArgs() { + return spawnMock.mock.calls[0]?.[1] as string[] | undefined; +} + describe("createPtyAdapter", () => { let createPtyAdapter: typeof import("./pty.js").createPtyAdapter; @@ -143,16 +151,55 @@ describe("createPtyAdapter", () => { await expect(adapter.wait()).resolves.toEqual({ code: 3, signal: null }); }); - it("keeps inherited env when no override env is provided", async () => { - const stub = createStubPty(); - spawnMock.mockReturnValue(stub); + it("keeps inherited env when no override env is provided on non-Linux", async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + try { + const stub = createStubPty(); + spawnMock.mockReturnValue(stub); - await createPtyAdapter({ - shell: "bash", - args: ["-lc", "env"], - }); + await createPtyAdapter({ + shell: "bash", + args: ["-lc", "env"], + }); - expect(expectSpawnEnv()).toBeUndefined(); + expect(expectSpawnCommand()).toBe("bash"); + expect(expectSpawnArgs()).toEqual(["-lc", "env"]); + expect(expectSpawnEnv()).toBeUndefined(); + } finally { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + } + }); + + it("wraps Linux PTY spawns so shell children inherit higher OOM score", async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + try { + const stub = createStubPty(); + spawnMock.mockReturnValue(stub); + + await createPtyAdapter({ + shell: "bash", + args: ["-lc", "env"], + env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv" }, + }); + } finally { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + } + + expect(expectSpawnCommand()).toBe("/bin/sh"); + expect(expectSpawnArgs()).toEqual([ + "-c", + 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"', + "bash", + "-lc", + "env", + ]); + expect(expectSpawnEnv()).toEqual({ PATH: "/usr/bin" }); }); it("passes explicit env overrides as strings", async () => { diff --git a/src/process/supervisor/adapters/pty.ts b/src/process/supervisor/adapters/pty.ts index bbc7a53d578..e1bb7c3373b 100644 --- a/src/process/supervisor/adapters/pty.ts +++ b/src/process/supervisor/adapters/pty.ts @@ -1,4 +1,5 @@ import { killProcessTree } from "../../kill-tree.js"; +import { prepareOomScoreAdjustedSpawn } from "../../linux-oom-score.js"; import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js"; import { toStringEnv } from "./env.js"; @@ -55,9 +56,11 @@ export async function createPtyAdapter(params: { if (!spawn) { throw new Error("PTY support is unavailable (node-pty spawn not found)."); } - const pty = spawn(params.shell, params.args, { + const baseEnv = params.env ? toStringEnv(params.env) : undefined; + const preparedSpawn = prepareOomScoreAdjustedSpawn(params.shell, params.args, { env: baseEnv }); + const pty = spawn(preparedSpawn.command, preparedSpawn.args, { cwd: params.cwd, - env: params.env ? toStringEnv(params.env) : undefined, + env: preparedSpawn.env ? toStringEnv(preparedSpawn.env) : undefined, name: params.name ?? process.env.TERM ?? "xterm-256color", cols: params.cols ?? 120, rows: params.rows ?? 30,