mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 14:41:13 +00:00
* fix(kill-tree): verify process group leader before group kill to prevent gateway SIGTERM (#76259) - Add isProcessGroupLeader() to killProcessTree/signalProcessTree: ps -p <pid> -o pgid= primary check with /proc/<pid>/stat fallback on Linux. Group kill only when the PID is its own process group leader; non-leaders fall back to single-pid kill, preventing accidental gateway SIGTERM when a non-detached child shares the gateway's process group. - Propagate detached: true to all detached-spawn cleanup callers (exec-termination, agent-bundle LSP, mcp-stdio, bash, supervisor pty, agent-core nodejs) so detached group cleanup survives leader exit. - Gateway/daemon cleanup paths (schtasks, restart-health) keep the leader-checked default (detached omitted). Closes #76259 Co-Authored-By: Claude <noreply@anthropic.com> * refactor(process): tighten process-group ownership checks * refactor(daemon): split restart diagnostics * refactor(daemon): isolate restart health types --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
import process from "node:process";
|
|
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
|
|
import { COMMAND_PROCESS_TREE_KILL_GRACE_MS, spawnCommand } from "./exec-spawn.js";
|
|
import { killProcessTree as terminateProcessTree } from "./kill-tree.js";
|
|
|
|
const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
|
|
|
|
type TerminationChild = {
|
|
pid?: number;
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
};
|
|
|
|
export function createCommandTerminationController(params: {
|
|
child: TerminationChild;
|
|
cancelController: AbortController;
|
|
baseEnv?: NodeJS.ProcessEnv;
|
|
env?: NodeJS.ProcessEnv;
|
|
killProcessTree?: boolean;
|
|
isChildExited: () => boolean;
|
|
isCommandSettled: () => boolean;
|
|
}): { terminate: () => boolean; settle: () => Promise<void> } {
|
|
let processTreeSettleAt: number | undefined;
|
|
let windowsTerminationPromise: Promise<void> | undefined;
|
|
|
|
const isDirectChildAlive = () =>
|
|
!params.isChildExited() && params.child.exitCode == null && params.child.signalCode == null;
|
|
const killDirectChild = () => {
|
|
if (isDirectChildAlive()) {
|
|
params.child.kill("SIGKILL");
|
|
}
|
|
};
|
|
const spawnTaskkillOrFallback = (args: string[], onSpawnError: () => void) => {
|
|
try {
|
|
const taskkillChild = spawnCommand([getWindowsSystem32ExePath("taskkill.exe"), ...args], {
|
|
baseEnv: params.baseEnv,
|
|
env: params.env,
|
|
forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
|
|
reject: false,
|
|
stdio: "ignore",
|
|
timeout: WINDOWS_TASKKILL_TIMEOUT_MS,
|
|
});
|
|
return taskkillChild.then(
|
|
(result) => {
|
|
if (result.failed && result.exitCode === undefined) {
|
|
onSpawnError();
|
|
}
|
|
return result;
|
|
},
|
|
() => {
|
|
onSpawnError();
|
|
return undefined;
|
|
},
|
|
);
|
|
} catch {
|
|
onSpawnError();
|
|
return undefined;
|
|
}
|
|
};
|
|
const startWindowsTermination = (childPid: number, graceful: boolean): void => {
|
|
const taskkills: Promise<unknown>[] = [];
|
|
const startTaskkill = (args: string[]) => {
|
|
const taskkill = spawnTaskkillOrFallback(args, killDirectChild);
|
|
if (taskkill) {
|
|
taskkills.push(taskkill);
|
|
}
|
|
};
|
|
windowsTerminationPromise = (async () => {
|
|
if (graceful) {
|
|
startTaskkill(["/PID", String(childPid), "/T"]);
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, COMMAND_PROCESS_TREE_KILL_GRACE_MS);
|
|
timer.unref();
|
|
});
|
|
if (isDirectChildAlive()) {
|
|
startTaskkill(["/PID", String(childPid), "/T", "/F"]);
|
|
}
|
|
} else {
|
|
startTaskkill(["/PID", String(childPid), "/T", "/F"]);
|
|
}
|
|
// taskkill owns the live PID while it enumerates descendants. Abort Execa
|
|
// only after every started taskkill settles, avoiding a reused-PID race.
|
|
await Promise.allSettled(taskkills);
|
|
if (!params.isCommandSettled()) {
|
|
params.cancelController.abort();
|
|
}
|
|
})();
|
|
};
|
|
|
|
const terminate = (): boolean => {
|
|
const childPid = params.child.pid;
|
|
const directChildAlive = isDirectChildAlive();
|
|
if (process.platform === "win32" && !directChildAlive) {
|
|
// taskkill /T requires a live root PID. Retrying a dead, reusable PID can
|
|
// target an unrelated tree; stronger ownership requires a spawn-time Job Object.
|
|
return false;
|
|
}
|
|
if (params.killProcessTree && typeof childPid === "number") {
|
|
processTreeSettleAt ??= Date.now() + COMMAND_PROCESS_TREE_KILL_GRACE_MS;
|
|
if (process.platform === "win32") {
|
|
startWindowsTermination(childPid, true);
|
|
return true;
|
|
}
|
|
terminateProcessTree(childPid, {
|
|
graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
|
|
detached: true,
|
|
});
|
|
return false;
|
|
}
|
|
if (!directChildAlive) {
|
|
return false;
|
|
}
|
|
if (process.platform === "win32" && typeof childPid === "number") {
|
|
startWindowsTermination(childPid, false);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const settle = async (): Promise<void> => {
|
|
if (windowsTerminationPromise) {
|
|
await windowsTerminationPromise;
|
|
}
|
|
if (
|
|
!params.killProcessTree ||
|
|
processTreeSettleAt === undefined ||
|
|
typeof params.child.pid !== "number"
|
|
) {
|
|
return;
|
|
}
|
|
// A direct child can exit before its descendants finish the graceful
|
|
// signal. Keep the wrapper pending through that grace window, then ensure
|
|
// the detached group cannot outlive the completed command result.
|
|
const remainingMs = Math.max(0, processTreeSettleAt - Date.now());
|
|
if (remainingMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, remainingMs);
|
|
});
|
|
}
|
|
if (process.platform !== "win32") {
|
|
terminateProcessTree(params.child.pid, { force: true, detached: true });
|
|
}
|
|
};
|
|
|
|
return { terminate, settle };
|
|
}
|