* 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)
175 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
}
|