Onur cd44a0d01e
fix: codex and similar processes keep dying on pty, solved by refactoring process spawning (#14257)
* exec: clean up PTY resources on timeout and exit

* cli: harden resume cleanup and watchdog stalled runs

* cli: productionize PTY and resume reliability paths

* docs: add PTY process supervision architecture plan

* docs: rewrite PTY supervision plan as pre-rewrite baseline

* docs: switch PTY supervision plan to one-go execution

* docs: add one-line root cause to PTY supervision plan

* docs: add OS contracts and test matrix to PTY supervision plan

* docs: define process-supervisor package placement and scope

* docs: tie supervisor plan to existing CI lanes

* docs: place PTY supervisor plan under src/process

* refactor(process): route exec and cli runs through supervisor

* docs(process): refresh PTY supervision plan

* wip

* fix(process): harden supervisor timeout and PTY termination

* fix(process): harden supervisor adapters env and wait handling

* ci: avoid failing formal conformance on comment permissions

* test(ui): fix cron request mock argument typing

* fix(ui): remove leftover conflict marker

* fix: supervise PTY processes (#14257) (openclaw#14257) (thanks @onutc)
2026-02-16 02:32:05 +01:00

175 lines
4.4 KiB
TypeScript

import type { ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
import type { ManagedRunStdin } from "../types.js";
import { killProcessTree } from "../../kill-tree.js";
import { spawnWithFallback } from "../../spawn-utils.js";
function resolveCommand(command: string): string {
if (process.platform !== "win32") {
return command;
}
const lower = command.toLowerCase();
if (lower.endsWith(".exe") || lower.endsWith(".cmd") || lower.endsWith(".bat")) {
return command;
}
const basename = lower.split(/[\\/]/).pop() ?? lower;
if (basename === "npm" || basename === "pnpm" || basename === "yarn" || basename === "npx") {
return `${command}.cmd`;
}
return command;
}
function toStringEnv(env?: NodeJS.ProcessEnv): Record<string, string> {
if (!env) {
return {};
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
continue;
}
out[key] = String(value);
}
return out;
}
export type ChildAdapter = {
pid?: number;
stdin?: ManagedRunStdin;
onStdout: (listener: (chunk: string) => void) => void;
onStderr: (listener: (chunk: string) => void) => void;
wait: () => Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
kill: (signal?: NodeJS.Signals) => void;
dispose: () => void;
};
export async function createChildAdapter(params: {
argv: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
input?: string;
stdinMode?: "inherit" | "pipe-open" | "pipe-closed";
}): Promise<ChildAdapter> {
const resolvedArgv = [...params.argv];
resolvedArgv[0] = resolveCommand(resolvedArgv[0] ?? "");
const stdinMode = params.stdinMode ?? (params.input !== undefined ? "pipe-closed" : "inherit");
const options: SpawnOptions = {
cwd: params.cwd,
env: params.env ? toStringEnv(params.env) : undefined,
stdio: ["pipe", "pipe", "pipe"],
detached: true,
windowsHide: true,
windowsVerbatimArguments: params.windowsVerbatimArguments,
};
if (stdinMode === "inherit") {
options.stdio = ["inherit", "pipe", "pipe"];
} else {
options.stdio = ["pipe", "pipe", "pipe"];
}
const spawned = await spawnWithFallback({
argv: resolvedArgv,
options,
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
});
const child = spawned.child as ChildProcessWithoutNullStreams;
if (child.stdin) {
if (params.input !== undefined) {
child.stdin.write(params.input);
child.stdin.end();
} else if (stdinMode === "pipe-closed") {
child.stdin.end();
}
}
const stdin: ManagedRunStdin | undefined = child.stdin
? {
destroyed: false,
write: (data: string, cb?: (err?: Error | null) => void) => {
try {
child.stdin.write(data, cb);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
child.stdin.end();
} catch {
// ignore close errors
}
},
destroy: () => {
try {
child.stdin.destroy();
} catch {
// ignore destroy errors
}
},
}
: undefined;
const onStdout = (listener: (chunk: string) => void) => {
child.stdout.on("data", (chunk) => {
listener(chunk.toString());
});
};
const onStderr = (listener: (chunk: string) => void) => {
child.stderr.on("data", (chunk) => {
listener(chunk.toString());
});
};
const wait = async () =>
await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => {
resolve({ code, signal });
});
});
const kill = (signal?: NodeJS.Signals) => {
const pid = child.pid ?? undefined;
if (signal === undefined || signal === "SIGKILL") {
if (pid) {
killProcessTree(pid);
} else {
try {
child.kill("SIGKILL");
} catch {
// ignore kill errors
}
}
return;
}
try {
child.kill(signal);
} catch {
// ignore kill errors for non-kill signals
}
};
const dispose = () => {
child.removeAllListeners();
};
return {
pid: child.pid ?? undefined,
stdin,
onStdout,
onStderr,
wait,
kill,
dispose,
};
}