UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

244 lines (243 loc) 8.1 kB
import { A as resolvePositiveTimerTimeoutMs, M as resolveTimestampMsToIsoString, j as resolveTimerTimeoutMs } from "./number-coercion-CJQ8TR--.js"; import { s as resolveConfigPath, u as resolveGatewayLockDir, y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { Rn as string, Tn as object, wn as number } from "./schemas-6cH6bZ7o.js"; import { t as safeParseJsonWithSchema } from "./zod-parse-Bip-sZi_.js"; import { n as isPidAlive } from "./pid-alive-C4bVUgUC.js"; import { n as parseProcCmdline, r as parseWindowsCmdline, t as isGatewayArgv } from "./gateway-process-argv-BPpPk56q.js"; import fs from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { execFileSync } from "node:child_process"; import net from "node:net"; import { createHash } from "node:crypto"; //#region src/infra/gateway-lock.ts const DEFAULT_TIMEOUT_MS = 5e3; const DEFAULT_POLL_INTERVAL_MS = 100; const DEFAULT_STALE_MS = 3e4; const DEFAULT_PORT_PROBE_TIMEOUT_MS = 1e3; const LockPayloadSchema = object({ pid: number(), createdAt: string(), configPath: string(), startTime: number().optional() }); var GatewayLockError = class extends Error { constructor(message, cause) { super(message); this.cause = cause; this.name = "GatewayLockError"; } }; function readLinuxCmdline(pid) { try { return parseProcCmdline(fs.readFileSync(`/proc/${pid}/cmdline`, "utf8")); } catch { return null; } } const CMDLINE_EXEC_TIMEOUT_MS = 1e3; /** * Read the command line of a Windows process via `wmic`. * Returns an argv-style array, or null when the lookup fails (process gone, * `wmic` missing/deprecated, timeout, etc.). */ function readWindowsCmdline(pid) { try { const buf = execFileSync("wmic", [ "process", "where", `processid=${pid}`, "get", "CommandLine", "/value" ], { timeout: CMDLINE_EXEC_TIMEOUT_MS, windowsHide: true, stdio: [ "ignore", "pipe", "ignore" ] }); const match = (buf.length >= 2 && buf[0] === 255 && buf[1] === 254 ? buf.toString("utf16le") : buf.toString("utf8")).match(/CommandLine=(.+)/); if (!match) return null; return parseWindowsCmdline(match[1].trim()); } catch { return null; } } /** * Read the command line of a macOS/BSD process via `ps`. * * `ps -o command=` outputs an unquoted flat string, so the naive whitespace * split will misparse paths containing spaces. This is acceptable because * standard macOS install paths do not contain spaces, and when the split * does fail the caller falls back to "alive" (conservative). */ function readDarwinCmdline(pid) { try { const line = execFileSync("ps", [ "-p", String(pid), "-o", "command=" ], { encoding: "utf8", timeout: CMDLINE_EXEC_TIMEOUT_MS, stdio: [ "ignore", "pipe", "ignore" ] }).trim(); if (!line) return null; return line.split(/\s+/).filter(Boolean); } catch { return null; } } function readLinuxStartTime(pid) { try { const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8").trim(); const closeParen = raw.lastIndexOf(")"); if (closeParen < 0) return null; const fields = raw.slice(closeParen + 1).trim().split(/\s+/); const startTime = Number.parseInt(fields[19] ?? "", 10); return Number.isFinite(startTime) ? startTime : null; } catch { return null; } } async function checkPortFree(port, host = "127.0.0.1") { return await new Promise((resolve) => { const socket = net.createConnection({ port, host }); let settled = false; const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); socket.removeAllListeners(); socket.destroy(); resolve(result); }; const timer = setTimeout(() => { finish(true); }, DEFAULT_PORT_PROBE_TIMEOUT_MS); socket.once("connect", () => { finish(false); }); socket.once("error", () => { finish(true); }); }); } function defaultReadProcessCmdline(pid, platform) { if (platform === "linux") return readLinuxCmdline(pid); if (platform === "win32") return readWindowsCmdline(pid); if (platform === "darwin") return readDarwinCmdline(pid); return null; } async function resolveGatewayOwnerStatus(pid, payload, platform, port, readCmdline) { if (port != null) { if (await checkPortFree(port)) return "dead"; } if (!isPidAlive(pid)) return "dead"; if (platform === "linux") { const payloadStartTime = payload?.startTime; if (Number.isFinite(payloadStartTime)) { const currentStartTime = readLinuxStartTime(pid); if (currentStartTime == null) return "unknown"; return currentStartTime === payloadStartTime ? "alive" : "dead"; } } const args = (readCmdline ?? ((p) => defaultReadProcessCmdline(p, platform)))(pid); if (!args) return platform === "linux" ? "unknown" : "alive"; return isGatewayArgv(args) ? "alive" : "dead"; } async function readLockPayload(lockPath) { try { return safeParseJsonWithSchema(LockPayloadSchema, await fs$1.readFile(lockPath, "utf8")); } catch { return null; } } function resolveGatewayLockPath(env, lockDir = resolveGatewayLockDir()) { const configPath = resolveConfigPath(env, resolveStateDir(env)); const hash = createHash("sha256").update(configPath).digest("hex").slice(0, 8); return { lockPath: path.join(lockDir, `gateway.${hash}.lock`), configPath }; } async function acquireGatewayLock(opts = {}) { const env = opts.env ?? process.env; const allowInTests = opts.allowInTests === true; if (env.OPENCLAW_ALLOW_MULTI_GATEWAY === "1" || !allowInTests && (env.VITEST || env.NODE_ENV === "test")) return null; const timeoutMs = resolveTimerTimeoutMs(opts.timeoutMs, DEFAULT_TIMEOUT_MS, 0); const pollIntervalMs = resolvePositiveTimerTimeoutMs(opts.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS); const staleMs = resolveTimerTimeoutMs(opts.staleMs, DEFAULT_STALE_MS, 0); const platform = opts.platform ?? process.platform; const port = opts.port; const now = opts.now ?? Date.now; const sleep = opts.sleep ?? (async (ms) => await new Promise((resolve) => { setTimeout(resolve, ms); })); const { lockPath, configPath } = resolveGatewayLockPath(env, opts.lockDir); await fs$1.mkdir(path.dirname(lockPath), { recursive: true }); const startedAt = now(); let lastPayload = null; while (now() - startedAt < timeoutMs) try { const handle = await fs$1.open(lockPath, "wx"); const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null; const payload = { pid: process.pid, createdAt: resolveTimestampMsToIsoString(now()), configPath }; if (typeof startTime === "number" && Number.isFinite(startTime)) payload.startTime = startTime; await handle.writeFile(JSON.stringify(payload), "utf8"); return { lockPath, configPath, release: async () => { await handle.close().catch(() => void 0); await fs$1.rm(lockPath, { force: true }); } }; } catch (err) { if (err.code !== "EEXIST") throw new GatewayLockError(`failed to acquire gateway lock at ${lockPath}`, err); lastPayload = await readLockPayload(lockPath); const ownerPid = lastPayload?.pid; const ownerStatus = ownerPid ? await resolveGatewayOwnerStatus(ownerPid, lastPayload, platform, port, opts.readProcessCmdline) : "unknown"; if (ownerStatus === "dead" && ownerPid) { await fs$1.rm(lockPath, { force: true }); continue; } if (ownerStatus !== "alive") { let stale = false; if (lastPayload?.createdAt) { const createdAt = Date.parse(lastPayload.createdAt); stale = Number.isFinite(createdAt) ? now() - createdAt > staleMs : false; } if (!stale) try { const st = await fs$1.stat(lockPath); stale = now() - st.mtimeMs > staleMs; } catch { stale = false; } if (stale) { await fs$1.rm(lockPath, { force: true }); continue; } } const remainingMs = timeoutMs - (now() - startedAt); if (remainingMs <= 0) break; await sleep(Math.min(pollIntervalMs, remainingMs)); } throw new GatewayLockError(`gateway already running${lastPayload?.pid ? ` (pid ${lastPayload.pid})` : ""}; lock timeout after ${timeoutMs}ms`); } //#endregion export { acquireGatewayLock as n, GatewayLockError as t };