mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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>
This commit is contained in:
@@ -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.<name>:<index>`) 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[-<profile>].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/<child-pid>/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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<void>((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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
105
src/process/linux-oom-score.test.ts
Normal file
105
src/process/linux-oom-score.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
143
src/process/linux-oom-score.ts
Normal file
143
src/process/linux-oom-score.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ChildAdapter> {
|
||||
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
|
||||
? [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user