openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
422 lines (421 loc) • 14.7 kB
JavaScript
import { opendirSync, readFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
//#region packages/agent-core/src/harness/env/kill-tree.ts
const DEFAULT_GRACE_MS = 3e3;
const MAX_GRACE_MS = 6e4;
const TASKKILL_COMPLETION_TIMEOUT_MS = 3e3;
const UNIX_PROCESS_TREE_TIMEOUT_MS = 500;
const MAX_UNIX_PROCESS_TREE_PIDS = 4096;
const MAX_UNIX_PROCESS_TREE_DEPTH = 128;
/**
* Best-effort process-tree termination with graceful shutdown.
* - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent
* first (without /F), then force-kills if taskkill refuses or the process
* survives the grace period.
* - Unix: send SIGTERM to the process group when ownership is known, wait the
* grace period, then SIGKILL. Linux attached children are enumerated and
* signaled descendants-first because their process group belongs to the gateway.
*
* Group kill (`process.kill(-pid, ...)`) is only used when the PID is verified
* as its own process group leader, unless `detached: true` is explicitly passed.
* This prevents accidentally signaling the gateway's process group when the
* child shares its parent's group.
*
* - `detached: false`: skip group kill unconditionally.
* - `detached: true`: use group kill unconditionally (trust caller).
* - `detached` omitted: use group kill only when PID is the group leader.
*/
function killProcessTree(pid, opts) {
if (!Number.isFinite(pid) || pid <= 0) return;
if (process.platform === "win32") {
if (opts?.force === true) {
signalProcessTreeWindowsAndWait(pid, "SIGKILL");
return;
}
killProcessTreeWindows(pid, normalizeGraceMs(opts?.graceMs));
return;
}
const useGroupKill = opts?.detached === true || opts?.detached !== false && isProcessGroupLeader(pid);
const attachedLinuxTree = opts?.detached === false && !useGroupKill && process.platform === "linux";
const processTree = attachedLinuxTree ? collectUnixProcessTree(pid) : void 0;
if (attachedLinuxTree && !processTree) {
signalProcessTreeUnix(pid, opts?.force === true ? "SIGKILL" : "SIGTERM", false);
return;
}
let forceRequested = false;
const force = () => {
if (forceRequested) return;
forceRequested = true;
const liveProcessTree = processTree?.filter(verifiedProcessInstanceAlive) ?? [];
if (!(useGroupKill ? isProcessAlive(-pid) : attachedLinuxTree ? liveProcessTree.length > 0 : isProcessAlive(pid))) return;
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill, processTree ? liveProcessTree : void 0);
};
if (opts?.force === true) {
if (processTree) force();
else signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
return;
}
signalProcessTreeUnix(pid, "SIGTERM", useGroupKill, processTree);
const graceMs = normalizeGraceMs(opts?.graceMs);
const forceTimer = setTimeout(force, graceMs);
if (!attachedLinuxTree) forceTimer.unref();
return { force: () => {
clearTimeout(forceTimer);
force();
} };
}
function signalProcessTree(pid, signal, opts) {
if (!Number.isFinite(pid) || pid <= 0) {
opts?.onComplete?.();
return;
}
if (process.platform === "win32") {
signalProcessTreeWindowsAndWait(pid, signal).then(opts?.onComplete);
return;
}
const useGroupKill = opts?.detached === true || opts?.detached !== false && isProcessGroupLeader(pid);
const attachedLinuxTree = opts?.detached === false && !useGroupKill && process.platform === "linux";
const processTree = attachedLinuxTree ? collectUnixProcessTree(pid) : void 0;
if (attachedLinuxTree && !processTree) {
signalProcessTreeUnix(pid, signal, false);
opts?.onComplete?.();
return;
}
signalProcessTreeUnix(pid, signal, useGroupKill, processTree);
opts?.onComplete?.();
}
/** Capture the group selected by signalProcessTree while its leader still exists. */
function readUnixProcessGroupMembers(pid) {
if (process.platform === "win32" || !isProcessGroupLeader(pid)) return [pid];
try {
const result = spawnSync("ps", ["-axo", "pid=,pgid="], {
encoding: "utf8",
timeout: 500
});
if (result.error || result.status !== 0) return [pid];
const members = /* @__PURE__ */ new Set([pid]);
for (const line of result.stdout.split("\n")) {
const [memberText, groupText] = line.trim().split(/\s+/);
const member = parseProcessGroupId(memberText);
if (member && parseProcessGroupId(groupText) === pid) members.add(member);
}
return [...members];
} catch {
return [pid];
}
}
/** Signals every process group and process still owned by one forkpty session. */
function signalPtySessionTree(pid, signal) {
if (!Number.isFinite(pid) || pid <= 0) return;
if (process.platform === "win32") {
signalProcessTreeWindowsAndWait(pid, signal);
return;
}
const darwinTty = process.platform === "darwin" ? readDarwinPtyTty(pid) : void 0;
if (process.platform === "darwin" && !darwinTty) {
signalUnixTarget(-pid, signal);
return;
}
const members = readProcessSessionMembers(pid, darwinTty);
if (!members) {
signalUnixTarget(-pid, signal);
return;
}
const signalMembers = (snapshot) => {
const groups = new Set(snapshot.map((member) => member.pgid));
groups.delete(pid);
for (const pgid of groups) signalUnixTarget(-pgid, signal);
for (const member of snapshot) if (member.pid !== pid) signalUnixTarget(member.pid, signal);
};
signalMembers(members);
const remaining = readProcessSessionMembers(pid, darwinTty);
if (remaining) signalMembers(remaining);
signalUnixTarget(-pid, signal);
signalUnixTarget(pid, signal);
}
function normalizeGraceMs(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GRACE_MS;
return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value)));
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
} catch (error) {
if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) !== "EPERM") return false;
}
if (pid < 0 || process.platform !== "linux") return true;
try {
const status = readFileSync(`/proc/${pid}/status`, "utf8");
return !(status.match(/^State:\s+(\S)/m)?.[1] === "Z" && /^Threads:[ \t]+1[ \t]*$/m.test(status));
} catch {
return true;
}
}
function parseProcessGroupId(value) {
if (typeof value !== "string" || !/^\d+$/.test(value.trim())) return;
const pgid = Number(value.trim());
return Number.isSafeInteger(pgid) && pgid > 0 ? pgid : void 0;
}
function readProcessGroupIdFromPs(pid) {
try {
const res = spawnSync("ps", [
"-p",
String(pid),
"-o",
"pgid="
], {
encoding: "utf8",
timeout: 500
});
if (res.error || res.status !== 0) return;
return parseProcessGroupId(res.stdout);
} catch {
return;
}
}
function readProcessGroupIdFromProc(pid) {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commEnd = stat.lastIndexOf(")");
if (commEnd < 0) return;
return parseProcessGroupId(stat.slice(commEnd + 1).trim().split(/\s+/)[2]);
} catch {
return;
}
}
function readDarwinPtyTty(sessionLeaderPid) {
try {
const leader = spawnSync("ps", [
"-p",
String(sessionLeaderPid),
"-o",
"tty="
], {
encoding: "utf8",
timeout: 500
});
const tty = leader.stdout.trim();
if (leader.error || leader.status !== 0 || !tty || tty === "?" || tty === "??") return;
return tty;
} catch {
return;
}
}
function readProcessSessionMembers(sessionId, darwinTty) {
try {
const expectedSession = darwinTty ?? String(sessionId);
const result = spawnSync("ps", darwinTty ? [
"-t",
darwinTty,
"-o",
"pid=,pgid="
] : ["-axo", "pid=,pgid=,sid="], {
encoding: "utf8",
timeout: 500
});
if (result.error || result.status !== 0) return;
const members = [];
for (const line of result.stdout.split("\n")) {
const [pidText, pgidText, session] = line.trim().split(/\s+/);
const pid = parseProcessGroupId(pidText);
const pgid = parseProcessGroupId(pgidText);
if (pid && pgid && (darwinTty || session === expectedSession)) members.push({
pid,
pgid
});
}
return members;
} catch {
return;
}
}
/** Fail closed to direct-PID signaling when group ownership cannot be proved. */
function isProcessGroupLeader(pid) {
return ((process.platform === "linux" ? readProcessGroupIdFromProc(pid) : void 0) ?? readProcessGroupIdFromPs(pid)) === pid;
}
function parsePositivePids(value) {
if (typeof value !== "string") return [];
return value.trim().split(/\s+/u).map((entry) => Number(entry)).filter((entry) => Number.isSafeInteger(entry) && entry > 0);
}
/**
* Read a stable process-instance identity for `pid`. Linux `starttime` from
* `/proc/<pid>/stat` distinguishes recycled PIDs. Other Unix platforms do not
* expose a sufficiently precise portable identity, so attached snapshots stay
* disabled there rather than authorizing a stale signal.
*/
function readUnixProcessIdentity(pid, deadlineMs) {
return readUnixProcessInstance(pid, deadlineMs)?.identity;
}
/**
* Read a process-instance identity and its current parent PID from
* `/proc/<pid>/stat`. The `ppid` lets the caller revalidate that a numeric PID
* reported by `/proc/<parent>/children` still belongs to the verified parent at
* capture time, so a PID reused between the children read and this read cannot
* be admitted as an authorized descendant.
*/
function readUnixProcessInstance(pid, deadlineMs) {
if (deadlineMs !== void 0 && Date.now() >= deadlineMs) return;
if (process.platform !== "linux") return;
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commEnd = stat.lastIndexOf(")");
if (commEnd < 0) return;
const fields = stat.slice(commEnd + 1).trim().split(/\s+/);
const ppid = Number(fields[1]);
const starttime = fields[19];
if (!starttime || !/^\d+$/u.test(starttime) || !Number.isSafeInteger(ppid)) return;
return {
identity: `${pid}:${starttime}`,
ppid
};
} catch {
return;
}
}
/**
* True when the captured process instance is still alive at the same identity.
* Missing identities are never eligible for an attached-tree signal.
*/
function verifiedProcessInstanceAlive(entry) {
if (!entry.identity || !isProcessAlive(entry.pid)) return false;
return readUnixProcessIdentity(entry.pid) === entry.identity;
}
function* readUnixProcessChildren(pid, canReadTask) {
let tasks;
try {
tasks = opendirSync(`/proc/${pid}/task`);
while (canReadTask()) {
const task = tasks.readSync();
if (!task) return;
const tid = parseProcessGroupId(task.name);
if (!tid) continue;
try {
yield* parsePositivePids(readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8"));
} catch {}
}
} catch {} finally {
try {
tasks?.closeSync();
} catch {}
}
}
/**
* Capture an attached process tree before signaling its root.
* Descendants are returned first so they retain their parent relationship.
*
* Each entry carries a stable process-instance identity captured at discovery
* time so delayed grace-period signals can be verified against the original
* instance instead of a numeric PID that may have been recycled. A descendant
* whose identity cannot be captured is omitted (fail-closed for that subtree)
* rather than retained as an identity-less PID that a delayed signal could
* target after reuse. The supervisor-owned root PID is always trusted as the
* caller's direct child, so the snapshot always contains it once the root's own
* identity verifies, even when every descendant probe fails.
*/
function collectUnixProcessTree(rootPid) {
if (process.platform !== "linux") return;
const descendants = [];
const seen = /* @__PURE__ */ new Set([rootPid]);
let taskReads = 0;
const deadline = Date.now() + UNIX_PROCESS_TREE_TIMEOUT_MS;
const rootIdentity = readUnixProcessIdentity(rootPid, deadline);
if (!rootIdentity || Date.now() >= deadline) return;
const withinBounds = (depth) => Date.now() < deadline && depth <= MAX_UNIX_PROCESS_TREE_DEPTH;
const hasCapacity = () => seen.size < MAX_UNIX_PROCESS_TREE_PIDS;
const visit = (parentPid, parentIdentity, depth) => {
if (!withinBounds(depth) || !hasCapacity()) return;
if (readUnixProcessIdentity(parentPid, deadline) !== parentIdentity) return;
const children = readUnixProcessChildren(parentPid, () => withinBounds(depth) && hasCapacity() && taskReads++ < MAX_UNIX_PROCESS_TREE_PIDS && readUnixProcessIdentity(parentPid, deadline) === parentIdentity);
for (const childPid of children) {
if (!withinBounds(depth + 1) || !hasCapacity()) return;
if (seen.has(childPid) || childPid === process.pid) continue;
seen.add(childPid);
const instance = readUnixProcessInstance(childPid, deadline);
if (!instance || instance.ppid !== parentPid || !withinBounds(depth + 1)) continue;
visit(childPid, instance.identity, depth + 1);
descendants.push({
pid: childPid,
identity: instance.identity
});
}
};
visit(rootPid, rootIdentity, 0);
return [...descendants, {
pid: rootPid,
identity: rootIdentity
}];
}
function signalProcessTreeUnix(pid, signal, useGroupKill, processTree) {
if (useGroupKill) {
signalUnixTarget(-pid, signal);
return;
}
const targets = processTree ?? [{ pid }];
for (const entry of targets) {
if (processTree && !verifiedProcessInstanceAlive(entry)) continue;
signalUnixTarget(entry.pid, signal);
}
}
function signalUnixTarget(pid, signal) {
try {
process.kill(pid, signal);
} catch {}
}
function runTaskkill(args, onExit) {
return new Promise((resolve) => {
let settled = false;
const finish = (code) => {
if (settled) return;
settled = true;
clearTimeout(completionTimer);
onExit?.(code);
resolve();
};
const completionTimer = setTimeout(() => finish(null), TASKKILL_COMPLETION_TIMEOUT_MS);
completionTimer.unref?.();
try {
const child = spawn("taskkill", args, {
stdio: "ignore",
detached: true,
windowsHide: true
});
child.once("error", () => finish(null));
child.once("close", (code) => finish(code));
} catch {
finish(null);
}
});
}
function killProcessTreeWindows(pid, graceMs) {
let forced = false;
let graceTimer;
const forceKill = () => {
if (forced) return;
forced = true;
if (graceTimer !== void 0) {
clearTimeout(graceTimer);
graceTimer = void 0;
}
if (!isProcessAlive(pid)) return;
signalProcessTreeWindowsAndWait(pid, "SIGKILL");
};
signalProcessTreeWindowsAndWait(pid, "SIGTERM", (code) => {
if (code !== null && code !== 0) forceKill();
});
graceTimer = setTimeout(forceKill, graceMs);
graceTimer.unref();
}
function signalProcessTreeWindowsAndWait(pid, signal, onExit) {
return runTaskkill(signal === "SIGKILL" ? [
"/F",
"/T",
"/PID",
String(pid)
] : [
"/T",
"/PID",
String(pid)
], onExit);
}
//#endregion
export { signalPtySessionTree as i, readUnixProcessGroupMembers as n, signalProcessTree as r, killProcessTree as t };