UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

495 lines (494 loc) 19.8 kB
import { w as parseStrictPositiveInteger } from "./number-coercion-CLj0HTDM.js"; import { o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { b as uniqueValues } from "./string-normalization-DsCfAx8q.js"; import { t as killProcessTree } from "./kill-tree-CR2oLt9D.js"; import { t as hasErrnoCode } from "./errno-CkbDOfLk.js"; import { t as sleep } from "./sleep-D7nua6TP.js"; import { _ as resolveGatewayPort } from "./paths-D2sRr1a_.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { n as resolveDiagnosticProcessEnv } from "./process-env-DlZFJzq6.js"; import { n as getWindowsInstallRoots } from "./windows-install-roots-BBaNTTOQ.js"; import { a as isGatewayArgv, c as parseProcCmdline, i as readWindowsProcessArgsSync, n as readWindowsListeningPidsResultSync, r as readWindowsProcessArgsResultSync, t as readWindowsListeningPidsOnPortSync } from "./windows-port-pids-XcH5-qBM.js"; import { n as resolveLsofCommandSync } from "./ports-lsof-BmdLddJi.js"; import { readFileSync } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; //#region src/infra/spawn-ps.ts /** Run a bounded ps probe without letting an ignored SIGTERM extend the synchronous wait. */ function spawnPsSync(args, timeoutMs) { return spawnSync("ps", args, { env: resolveDiagnosticProcessEnv(), encoding: "utf8", killSignal: "SIGKILL", timeout: timeoutMs }); } //#endregion //#region src/infra/restart-stale-pids.ts const INITIAL_LSOF_TIMEOUT_MS = 5e3; const PROCESS_INSPECTION_TIMEOUT_MS = 2e3; const STALE_SIGTERM_WAIT_MS = 600; const STALE_SIGKILL_WAIT_MS = 400; /** * After SIGKILL, the kernel may not release the TCP port immediately. * Poll until the port is confirmed free (or until the budget expires) before * returning control to the caller (typically `triggerOpenClawRestart` → * `systemctl restart`). Without this wait the new process races the dying * process for the port and systemd enters an EADDRINUSE restart loop. * * POLL_SPAWN_TIMEOUT_MS is intentionally much shorter than the initial scan * so that a single slow or hung lsof invocation does not consume the entire * polling budget. At 400 ms per call, up to five independent lsof attempts * fit within PORT_FREE_TIMEOUT_MS = 2000 ms, each with a definitive outcome. */ const PORT_FREE_POLL_INTERVAL_MS = 50; const PORT_FREE_TIMEOUT_MS = 2e3; const POLL_SPAWN_TIMEOUT_MS = 400; /** * Upper bound on the ancestor-PID walk. A real-world chain is shallow * (pid1 → systemd → gateway → plugin-host → sidecar ≈ 5); 32 generously covers * nested-supervisor setups (k8s pod → containerd-shim → runc → …) while still * providing a hard stop against corrupted process tables or ppid cycles. */ const MAX_ANCESTOR_WALK_DEPTH = 32; const restartLog = createSubsystemLogger("restart"); /** Terminate externally discovered stale gateway processes and allow cleanup to settle. */ async function terminateStaleGatewayPids(pids) { const targets = Array.from(new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0))); for (const pid of targets) killProcessTree(pid, { graceMs: 300 }); if (targets.length > 0) await sleep(500); return targets; } function getTimeMs() { return Date.now(); } function sleepSync(ms) { const timeoutMs = Math.max(0, Math.floor(ms)); if (timeoutMs <= 0) return; try { const lock = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(lock, 0, 0, timeoutMs); } catch { const start = Date.now(); while (Date.now() - start < timeoutMs); } } function getParentPid() { return process.ppid; } /** * Read a single ancestor PID from `/proc/<pid>/status` on Linux. * Returns null on any failure (non-Linux platform, restricted /proc, race * where the target pid exited between the walk step and the read); callers * treat a null return as "stop walking" and proceed with the ancestor set * collected so far. */ function readParentPidFromProc(pid) { try { const match = readFileSync(`/proc/${pid}/status`, "utf8").match(/^PPid:\s*(\d+)/m); if (!match) return null; const parsed = Number.parseInt(match[1] ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } catch { return null; } } function readParentPidFromPs(pid, spawnTimeoutMs) { try { const res = spawnPsSync([ "-o", "ppid=", "-p", String(pid) ], spawnTimeoutMs); if (res.error || res.status !== 0 || !res.stdout.trim()) return null; return parseStrictPositiveInteger(res.stdout.trim()) ?? null; } catch { return null; } } /** * Collect the set of PIDs whose termination would cascade-kill the caller: * the current process, its direct parent, and — where the platform permits * — the full ancestor chain up to the top of the pid namespace. * * Rationale: `cleanStaleGatewayProcessesSync` already refuses to kill * `process.pid` (see `parsePidsFromLsofOutput`), acknowledging the invariant * "a cleanup step must never destroy its own caller." That invariant was * applied only to the caller itself, not to its ancestors — which is how * issue #68451 arises: a plugin sidecar calls the cleanup, `lsof` reports * the parent gateway listening on 18789, the parent's PID passes the * `pid !== process.pid` filter, it is SIGTERM'd, the sidecar is then reaped * by the supervisor, the supervisor restarts the gateway, which re-spawns * the sidecar, which runs the cleanup again — infinite restart loop. * * Completing the invariant here removes the loop at its source: killing any * ancestor is exactly as fatal to the caller as killing itself, so ancestors * must receive the same exclusion treatment. The check admits any positive * ancestor PID (including 1), because inside a container — a first-class * deployment target for this project — the gateway is frequently the * entrypoint and therefore runs as PID 1 of its own namespace; excluding 1 * unconditionally would recreate the #68451 loop on every containerised * install where the gateway spawns a direct-child sidecar. * * The walk is best-effort. `process.ppid` is provided by Node via a direct * syscall and is always available; transitive ancestors are read on Linux via * `/proc` and on macOS via `ps`. Windows stops at ppid. * * The function exposes no runtime hooks. Tests exercise the real walk by * stubbing `process.ppid` (and, on Linux, by mocking `node:fs` to inject * `/proc/<pid>/status` payloads) — there is no reachable override for * runtime callers to mutate. */ function getSelfAndAncestorPidsSync(spawnTimeoutMs = PROCESS_INSPECTION_TIMEOUT_MS) { const pids = /* @__PURE__ */ new Set([process.pid]); const immediateParent = getParentPid(); if (!Number.isFinite(immediateParent) || immediateParent <= 0) return pids; pids.add(immediateParent); const readTransitiveParent = process.platform === "linux" ? readParentPidFromProc : process.platform === "darwin" ? (pid) => readParentPidFromPs(pid, spawnTimeoutMs) : null; if (!readTransitiveParent) return pids; let current = immediateParent; for (let depth = 0; depth < MAX_ANCESTOR_WALK_DEPTH; depth++) { const parent = readTransitiveParent(current); if (parent == null || parent <= 0 || pids.has(parent)) break; pids.add(parent); current = parent; } return pids; } function getExcludedGatewayPidsSync(spawnTimeoutMs, protectedPid) { const excluded = getSelfAndAncestorPidsSync(spawnTimeoutMs); if (typeof protectedPid === "number" && Number.isSafeInteger(protectedPid) && protectedPid > 0) excluded.add(protectedPid); return excluded; } /** * Parse raw PIDs from lsof -Fpc stdout, excluding the current * process and its ancestors (see `getSelfAndAncestorPidsSync` for the full * rationale). On Linux the ancestor lookup reads up to * `MAX_ANCESTOR_WALK_DEPTH` entries from `/proc/<pid>/status`; each read is * a virtual-filesystem access (no disk I/O, no external process), wrapped * in try/catch and degrades silently. On macOS the lookup shells out to `ps` * with the process-inspection timeout. Windows only uses the in-memory direct * parent from `process.ppid`. */ function parseLsofEntries(stdout) { const entries = []; let currentPid; let currentCmd; const flush = () => { if (currentPid != null) entries.push({ pid: currentPid, ...currentCmd ? { cmd: currentCmd } : {} }); }; for (const line of stdout.split(/\r?\n/).filter(Boolean)) if (line.startsWith("p")) { flush(); currentPid = parseStrictPositiveInteger(line.slice(1)) ?? void 0; currentCmd = void 0; } else if (line.startsWith("c")) currentCmd = line.slice(1); flush(); return entries; } function parsePsCommandLine(raw) { const args = []; for (const match of raw.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) { const value = match[1] ?? match[2] ?? match[3]; if (value) args.push(value); } return args; } function readUnixProcessArgsSync(pid, spawnTimeoutMs) { if (process.platform === "linux") try { const args = parseProcCmdline(readFileSync(`/proc/${pid}/cmdline`, "utf8")); if (args.length > 0) return args; } catch {} const res = spawnPsSync([ "-ww", "-p", String(pid), "-o", "command=" ], spawnTimeoutMs); if (res.error || res.status !== 0 || !res.stdout.trim()) return null; return parsePsCommandLine(res.stdout.trim()); } function verifyGatewayPidByArgvSync(pid, spawnTimeoutMs) { const args = readUnixProcessArgsSync(pid, spawnTimeoutMs); return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); } function parsePidsFromLsofOutput(stdout, spawnTimeoutMs, protectedPid) { const excluded = getExcludedGatewayPidsSync(spawnTimeoutMs, protectedPid); const pids = []; for (const entry of parseLsofEntries(stdout)) { if (excluded.has(entry.pid)) continue; if (entry.cmd && normalizeLowercaseStringOrEmpty(entry.cmd).includes("openclaw")) { pids.push(entry.pid); continue; } if (verifyGatewayPidByArgvSync(entry.pid, spawnTimeoutMs)) pids.push(entry.pid); } return uniqueValues(pids); } /** * Windows: find listening PIDs on the port, then verify each is an openclaw * gateway process via command-line inspection. Excludes the current process * and its ancestors (same invariant as the lsof path — see * `getSelfAndAncestorPidsSync`). */ function filterVerifiedWindowsGatewayPids(rawPids, protectedPid) { const excluded = getExcludedGatewayPidsSync(PROCESS_INSPECTION_TIMEOUT_MS, protectedPid); return uniqueValues(rawPids).filter((pid) => Number.isFinite(pid) && pid > 0 && !excluded.has(pid)).filter((pid) => { const args = readWindowsProcessArgsSync(pid); return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); }); } function filterVerifiedWindowsGatewayPidsResult(rawPids, processArgsResult, protectedPid) { const excluded = getExcludedGatewayPidsSync(PROCESS_INSPECTION_TIMEOUT_MS, protectedPid); const verified = []; for (const pid of uniqueValues(rawPids)) { if (!Number.isFinite(pid) || pid <= 0 || excluded.has(pid)) continue; const argsResult = processArgsResult(pid); if (!argsResult.ok) return { ok: false, permanent: argsResult.permanent }; if (argsResult.args != null && isGatewayArgv(argsResult.args, { allowGatewayBinary: true })) verified.push(pid); } return { ok: true, pids: verified }; } function resolveProtectedPidAfterEnumeration(options) { return options?.resolveProtectedPid ? options.resolveProtectedPid() : options?.protectedPid; } function findVerifiedWindowsGatewayPidsOnPortSync(port, options) { return filterVerifiedWindowsGatewayPids(readWindowsListeningPidsOnPortSync(port), resolveProtectedPidAfterEnumeration(options)); } function findVerifiedWindowsGatewayPidsOnPortResultSync(port, options) { const result = readWindowsListeningPidsResultSync(port); if (!result.ok) return result; return filterVerifiedWindowsGatewayPidsResult(result.pids, (pid) => readWindowsProcessArgsResultSync(pid), resolveProtectedPidAfterEnumeration(options)); } function findGatewayPidsOnPortWithProtectedPidSync(port, lsofTimeoutMs, processInspectionTimeoutMs, options) { if (process.platform === "win32") return findVerifiedWindowsGatewayPidsOnPortSync(port, options); const lsof = resolveLsofCommandSync(); const res = spawnSync(lsof, [ "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc" ], { env: resolveDiagnosticProcessEnv(), encoding: "utf8", timeout: lsofTimeoutMs }); if (res.error) { const code = res.error.code; if (code === "ENOENT") return []; const detail = code && code.trim().length > 0 ? code : res.error instanceof Error ? res.error.message : "unknown error"; restartLog.warn(`lsof failed during initial stale-pid scan for port ${port}: ${detail}`); return []; } if (res.status === 1) return []; if (res.status !== 0) { restartLog.warn(`lsof exited with status ${res.status} during initial stale-pid scan for port ${port}; skipping stale pid check`); return []; } return parsePidsFromLsofOutput(res.stdout, processInspectionTimeoutMs, resolveProtectedPidAfterEnumeration(options)); } /** * Find PIDs of gateway processes listening on the given port using synchronous lsof. * Returns only PIDs that belong to openclaw gateway processes (not the current process). */ function findGatewayPidsOnPortSync(port, spawnTimeoutMs) { return findGatewayPidsOnPortWithProtectedPidSync(port, spawnTimeoutMs ?? INITIAL_LSOF_TIMEOUT_MS, spawnTimeoutMs ?? PROCESS_INSPECTION_TIMEOUT_MS); } function pollPortOnce(port) { if (process.platform === "win32") return pollPortOnceWindows(port); try { const lsof = resolveLsofCommandSync(); const res = spawnSync(lsof, [ "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc" ], { env: resolveDiagnosticProcessEnv(), encoding: "utf8", timeout: POLL_SPAWN_TIMEOUT_MS }); if (res.error) { const code = res.error.code; return { free: null, permanent: code === "ENOENT" || code === "EACCES" || code === "EPERM" }; } if (res.status === 1) return res.stdout.trim() ? { free: false } : { free: true }; if (res.status !== 0) return { free: null, permanent: false }; return { free: false }; } catch { return { free: null, permanent: false }; } } /** * Windows-specific port poll. * Uses a short timeout (POLL_SPAWN_TIMEOUT_MS) so a single slow PowerShell * invocation cannot exceed the waitForPortFreeSync wall-clock budget. * Only checks whether any process is listening — no gateway verification * needed because we already killed the stale gateway in the prior step. */ function pollPortOnceWindows(port) { try { const result = readWindowsListeningPidsResultSync(port, POLL_SPAWN_TIMEOUT_MS); if (!result.ok) return { free: null, permanent: result.permanent }; return result.pids.length === 0 ? { free: true } : { free: false }; } catch { return { free: null, permanent: false }; } } /** * Synchronously terminate stale gateway processes. * Callers must pass a non-empty pids array. * * On Unix: sends SIGTERM, waits briefly, then SIGKILL for survivors. * On Windows: uses taskkill (graceful first, then /F for force-kill). */ function terminateStaleProcessesSync(pids) { if (process.platform === "win32") return terminateStaleProcessesWindows(pids); const killed = []; for (const pid of pids) if (trySignalStaleProcess(pid, "SIGTERM")) killed.push(pid); if (killed.length === 0) return killed; sleepSync(STALE_SIGTERM_WAIT_MS); for (const pid of killed) if (isProcessAlive(pid)) trySignalStaleProcess(pid, "SIGKILL"); sleepSync(STALE_SIGKILL_WAIT_MS); return killed; } function trySignalStaleProcess(pid, signal) { try { process.kill(pid, signal); return true; } catch (error) { if (!hasErrnoCode(error, "ESRCH")) restartLog.warn(`failed to send ${signal} to stale gateway process ${pid}: ${formatErrorMessage(error)}`); return false; } } /** * Windows-specific process termination using taskkill. * Sends a graceful taskkill first (/T for tree), waits, then escalates to /F. */ function terminateStaleProcessesWindows(pids) { const taskkillPath = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "taskkill.exe"); const killed = []; for (const pid of pids) { const graceful = spawnSync(taskkillPath, [ "/T", "/PID", String(pid) ], { stdio: "ignore", timeout: 5e3, windowsHide: true }); if (!(graceful.error != null || (graceful.status ?? 0) !== 0) && !isProcessAlive(pid)) { killed.push(pid); continue; } sleepSync(STALE_SIGTERM_WAIT_MS); if (!isProcessAlive(pid)) { killed.push(pid); continue; } const forced = spawnSync(taskkillPath, [ "/F", "/T", "/PID", String(pid) ], { stdio: "ignore", timeout: 5e3, windowsHide: true }); if (forced.error != null || (forced.status ?? 0) !== 0) continue; sleepSync(STALE_SIGKILL_WAIT_MS); if (!isProcessAlive(pid)) killed.push(pid); } return killed; } function isProcessAlive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } } /** * Poll the given port until it is confirmed free, lsof is confirmed unavailable, * or the wall-clock budget expires. * * Each poll invocation uses POLL_SPAWN_TIMEOUT_MS (400 ms), which is * significantly shorter than PORT_FREE_TIMEOUT_MS (2000 ms). This ensures * that a single slow or hung lsof call cannot consume the entire polling * budget and cause the function to exit prematurely with an inconclusive * result. Up to five independent lsof attempts fit within the budget. * * Exit conditions: * - `pollPortOnce` returns `{ free: true }` → port confirmed free * - `pollPortOnce` returns `{ free: null, permanent: true }` → lsof unavailable, bail * - `pollPortOnce` returns `{ free: false }` → port busy, sleep + retry * - `pollPortOnce` returns `{ free: null, permanent: false }` → transient error, sleep + retry * - Wall-clock deadline exceeded → log warning, proceed anyway */ function waitForPortFreeSync(port) { const deadline = getTimeMs() + PORT_FREE_TIMEOUT_MS; while (getTimeMs() < deadline) { const result = pollPortOnce(port); if (result.free === true) return; if (result.free === null && result.permanent) return; sleepSync(PORT_FREE_POLL_INTERVAL_MS); } restartLog.warn(`port ${port} still in use after ${PORT_FREE_TIMEOUT_MS}ms; proceeding anyway`); } /** * Inspect the gateway port and kill any stale gateway processes holding it. * Blocks until the port is confirmed free (or the poll budget expires) so * the supervisor (systemd / launchctl) does not race a zombie process for * the port and enter an EADDRINUSE restart loop. * * Called before service restart commands to prevent port conflicts. */ function cleanStaleGatewayProcessesSync(portOverride, options) { try { const port = typeof portOverride === "number" && Number.isFinite(portOverride) && portOverride > 0 ? Math.floor(portOverride) : resolveGatewayPort(void 0, process.env); const stalePids = process.platform === "win32" ? (() => { const result = findVerifiedWindowsGatewayPidsOnPortResultSync(port, options); if (result.ok) return result.pids; waitForPortFreeSync(port); return []; })() : findGatewayPidsOnPortWithProtectedPidSync(port, INITIAL_LSOF_TIMEOUT_MS, PROCESS_INSPECTION_TIMEOUT_MS, options); if (stalePids.length === 0) return []; restartLog.warn(`killing ${stalePids.length} stale gateway process(es) before restart: ${stalePids.join(", ")}`); const killed = terminateStaleProcessesSync(stalePids); waitForPortFreeSync(port); return killed; } catch { return []; } } //#endregion export { spawnPsSync as a, terminateStaleGatewayPids as i, findGatewayPidsOnPortSync as n, getSelfAndAncestorPidsSync as r, cleanStaleGatewayProcessesSync as t };