UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

522 lines (521 loc) • 18 kB
import { i as extractErrorCode, u as toErrorObject } from "./error-coercion-1P2One0X.mjs"; import { t as createDeferredCore } from "./deferred-D0La5CRk.mjs"; import { t as killProcessTree } from "./kill-tree-BDAzGPyP.mjs"; import { o as isPidDefinitelyDead } from "./pid-alive-BYw_9KTp.mjs"; import { n as runtimeProcessEntrypoints } from "./runtime-process-entrypoints-Bh4reDp0.mjs"; import { r as resolveRuntimeWorkerUrl, t as resolveRuntimeWorkerArgv } from "./runtime-worker-url-S0NIVxUR.mjs"; import { a as SpawnBrokerError, i as restoreStdinPipe, n as holdPipe, r as restorePipePrefix, t as BrokerChild } from "./child-BYjOrKW3.mjs"; import { fileURLToPath } from "node:url"; import { deserialize, serialize } from "node:v8"; import { spawn } from "node:child_process"; import { Socket } from "node:net"; import { setTimeout as setTimeout$1 } from "node:timers/promises"; //#region src/process/supervisor/cancellation-policy.ts const GRACEFUL_CANCEL_TIMEOUT_MS = 5e3; //#endregion //#region src/process/spawn-broker/cleanup.ts function groupGone(pid) { try { process.kill(-pid, 0); return false; } catch (error) { return extractErrorCode(error) === "ESRCH"; } } /** Preserve a lost broker's tree cleanup across host restart and process exit. */ function terminateLostBrokerChild(pid, detached, ownerExited) { const termination = killProcessTree(pid, { detached, graceMs: GRACEFUL_CANCEL_TIMEOUT_MS }); const graceEndsAt = Date.now() + GRACEFUL_CANCEL_TIMEOUT_MS; return { force: () => termination?.force(), settled: (async () => { let deadline = graceEndsAt + 2e3; if (ownerExited) { await ownerExited; deadline = Math.max(deadline, Date.now() + 2e3); } if (!detached && Date.now() < graceEndsAt) await setTimeout$1(graceEndsAt - Date.now()); for (;;) { if (isPidDefinitelyDead(pid) && (!detached || groupGone(pid))) break; if (Date.now() >= deadline) throw new SpawnBrokerError(`Spawn broker child cleanup could not be confirmed for pid=${pid}`); await setTimeout$1(50); } })() }; } /** A broker's private group retains non-detached children before PID publication. */ function terminateBrokerProcessGroup(pgid) { let retired = false; let signalError; let forceTimer; const signal = (value) => { try { process.kill(-pgid, value); } catch (error) { if (extractErrorCode(error) !== "ESRCH") signalError = error; } }; const retire = () => { retired = true; clearTimeout(forceTimer); }; const force = () => { if (retired) return; if (groupGone(pgid)) { retire(); return; } clearTimeout(forceTimer); signal("SIGKILL"); }; return { force, settled: (async () => { if (groupGone(pgid)) { retire(); return; } signal("SIGTERM"); forceTimer = setTimeout(force, GRACEFUL_CANCEL_TIMEOUT_MS); const deadline = Date.now() + GRACEFUL_CANCEL_TIMEOUT_MS + 2e3; try { while (!groupGone(pgid)) { if (Date.now() >= deadline) throw new SpawnBrokerError(`Spawn broker group cleanup could not be confirmed for pgid=${pgid}`, { cause: signalError }); await setTimeout$1(50); } } finally { retire(); } })() }; } //#endregion //#region src/process/spawn-broker/ipc.ts const FRAME_BYTES = 1048576; const MAX_PENDING_BYTES = 268435456; const MAX_PENDING_MESSAGES = 1024; const FRAME_KIND = "openclaw-spawn-broker-frame"; /** Frame large inputs/results while bounding both native writes and retained payloads. */ function createBrokerSender(send) { let bytes = 0; let messages = 0; let sequence = 0; let previous = Promise.resolve(); const write = (message, handle) => new Promise((resolve, reject) => { try { send(message, handle, (error) => error ? reject(error) : resolve()); } catch (error) { reject(toErrorObject(error, "Spawn broker IPC write failed")); } }); const enqueue = (size, operation) => { if (bytes + size > MAX_PENDING_BYTES || messages >= MAX_PENDING_MESSAGES) return Promise.reject(new SpawnBrokerError("Spawn broker IPC capacity exceeded")); bytes += size; messages += 1; const completion = previous.then(operation); previous = completion.then(() => {}, () => {}); return completion.finally(() => { bytes -= size; messages -= 1; }); }; const sender = (message, handle) => { const payload = serialize(message); const size = payload.byteLength; if (size > FRAME_BYTES && handle) return Promise.reject(new SpawnBrokerError("Spawn broker handle metadata exceeds one IPC frame")); const id = ++sequence; const operation = async () => { if (size <= FRAME_BYTES) { await write(message, handle); return; } for (let offset = 0; offset < size; offset += FRAME_BYTES) { const frame = { kind: FRAME_KIND, id, total: size, offset, bytes: payload.subarray(offset, offset + FRAME_BYTES) }; await write(frame); } }; return enqueue(size, operation); }; return Object.assign(sender, { reserve(run) { return enqueue(FRAME_BYTES, async () => { let active = true; let publication; const publish = (message) => { if (!active || publication) return Promise.reject(new SpawnBrokerError("Spawn broker publication reservation is closed")); publication = (async () => { if (serialize(message).byteLength > FRAME_BYTES) throw new SpawnBrokerError("Spawn broker reserved publication exceeds one IPC frame"); await write(message); })(); publication.catch(() => {}); return publication; }; try { return await run(publish); } finally { active = false; await publication; } }); } }); } /** Only the version-matched private peer can supply frames on this channel. */ function createBrokerReceiver() { const pending = /* @__PURE__ */ new Map(); let reserved = 0; return { receive(message) { if (!message || typeof message !== "object" || !("kind" in message) || message.kind !== FRAME_KIND) return message; const frame = message; if (!Number.isSafeInteger(frame.id) || !Number.isSafeInteger(frame.total) || frame.total <= FRAME_BYTES || frame.total > MAX_PENDING_BYTES || !Number.isSafeInteger(frame.offset) || !Buffer.isBuffer(frame.bytes) || frame.bytes.length === 0 || frame.bytes.length > FRAME_BYTES) throw new SpawnBrokerError("Invalid spawn broker IPC frame"); let assembly = pending.get(frame.id); if (!assembly) { if (frame.offset !== 0 || pending.size >= MAX_PENDING_MESSAGES || reserved + frame.total > MAX_PENDING_BYTES) throw new SpawnBrokerError("Spawn broker receive capacity exceeded"); assembly = { total: frame.total, received: 0, chunks: [] }; pending.set(frame.id, assembly); reserved += frame.total; } if (assembly.total !== frame.total || assembly.received !== frame.offset || assembly.received + frame.bytes.length > assembly.total) throw new SpawnBrokerError("Out-of-order spawn broker IPC frame"); assembly.chunks.push(frame.bytes); assembly.received += frame.bytes.length; if (assembly.received < assembly.total) return; pending.delete(frame.id); reserved -= assembly.total; return deserialize(Buffer.concat(assembly.chunks, assembly.total)); }, clear() { pending.clear(); reserved = 0; } }; } //#endregion //#region src/process/spawn-broker/host.ts const spawnBrokerWorkerUrl = resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.spawnBroker); const spawnBrokerEntryPath = fileURLToPath(spawnBrokerWorkerUrl); const MAX_REQUESTS = 256; const RESTART_DELAYS = [ 100, 250, 500, 1e3, 2e3 ]; function brokerSpawnOptions(options) { const stdio = options.stdio ?? "pipe"; const entries = typeof stdio === "string" ? [ stdio, stdio, stdio ] : [...stdio]; const normalized = []; for (let fd = 0; fd < Math.max(3, entries.length); fd += 1) { const entry = entries[fd] ?? (fd < 3 ? "pipe" : "ignore"); if (entry === "inherit" && fd !== 0) return; if (entry !== "pipe" && entry !== "ignore" && entry !== "inherit" && entry !== "ipc") return; normalized.push(entry); } if (options.signal || options.timeout || options.killSignal) throw new Error("Unsupported spawn broker cancellation options"); return { cwd: options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd, env: options.env ? { ...options.env } : { ...process.env }, argv0: options.argv0, detached: options.detached, shell: options.shell, windowsHide: options.windowsHide, windowsVerbatimArguments: options.windowsVerbatimArguments, serialization: options.serialization, uid: options.uid, gid: options.gid, stdio: normalized }; } var SpawnBrokerHost = class { constructor(options = {}) { this.options = options; this.readiness = createDeferredCore(); this.available = false; this.hasBeenReady = false; this.closing = false; this.generation = 0; this.consecutiveFailures = 0; this.sequence = 0; this.requests = /* @__PURE__ */ new Map(); this.cleanups = /* @__PURE__ */ new Set(); this.cleanupErrors = []; this.onParentExit = () => { if (this.process?.connected) this.process.disconnect(); for (const cleanup of this.cleanups) cleanup.force(); }; this.readiness.promise.catch(() => {}); this.start(); process.once("exit", this.onParentExit); } get pid() { return this.process?.pid; } ready() { return this.closing ? Promise.reject(new SpawnBrokerError("Spawn broker is closing")) : this.readiness.promise; } spawn(command, args, options) { const prepared = brokerSpawnOptions(options); if (!prepared) throw new Error("Unsupported spawn broker stdio or process options"); return this.admit({ type: "spawn", id: ++this.sequence, argv: [command, ...args], options: prepared }).child; } spawnExeca(argv, options) { const id = ++this.sequence; const request = this.admit({ type: "spawn-execa", id, argv, options }); return { child: request.child, result: request.result.promise, cancel: () => { this.transmit({ type: "cancel", id }).catch((error) => request.child.fail(toErrorObject(error, "Spawn broker cancellation delivery failed"))); } }; } admit(message) { const child = new BrokerChild(message.id, message.argv, (value, handle) => this.transmit(value, handle)); const result = message.type === "spawn-execa" ? createDeferredCore() : void 0; if (result) result.promise.catch(() => {}); const request = { child, result, childClosed: false, resultSettled: !result, detached: message.options.detached === true || message.type === "spawn-execa" && message.options.killDescendants === true }; const fail = (error) => { result?.reject(error); child.fail(error); }; if (!this.available || this.closing || this.requests.size >= MAX_REQUESTS) { child.markNotStarted(); queueMicrotask(() => fail(new SpawnBrokerError("Spawn broker is unavailable"))); return request; } this.requests.set(message.id, request); child.waitForClose().then(() => { request.childClosed = true; this.retire(message.id, request); }); this.transmit(message).catch((error) => { this.requests.delete(message.id); fail(new SpawnBrokerError("Spawn broker request delivery failed", { cause: error })); }); return request; } retire(id, request) { if (request.childClosed && request.resultSettled) this.requests.delete(id); } retainCleanup(cleanup) { this.cleanups.add(cleanup); cleanup.settled.then(() => this.cleanups.delete(cleanup), (failure) => { this.cleanupErrors.push(toErrorObject(failure, "Spawn broker cleanup failed")); this.cleanups.delete(cleanup); }); } transmit(message, handle) { if (!this.available || !this.sendMessage) return Promise.reject(new SpawnBrokerError("Spawn broker is unavailable")); return this.sendMessage(message, handle); } start() { if (this.closing) return; const generation = this.generation++; const child = spawn(process.execPath, resolveRuntimeWorkerArgv(spawnBrokerWorkerUrl), { stdio: [ "inherit", "ignore", "ignore", "ipc" ], detached: true, serialization: "advanced" }); this.process = child; this.sendMessage = createBrokerSender((message, handle, callback) => child.send(message, handle, { keepOpen: true }, callback)); const receiver = createBrokerReceiver(); const brokerExited = createDeferredCore(); let ended = false; const startupTimer = setTimeout(() => { fail(/* @__PURE__ */ new Error("readiness deadline exceeded after 15000ms")); child.kill("SIGKILL"); }, 15e3); const fail = (cause) => { if (ended) return; ended = true; clearTimeout(startupTimer); receiver.clear(); this.available = false; this.sendMessage = void 0; const error = new SpawnBrokerError(this.hasBeenReady ? "Spawn broker exited; command outcome is unavailable" : `Spawn broker failed before readiness: ${cause?.message ?? "channel lost"}`, { cause }); const previousReadiness = this.readiness; this.readiness = createDeferredCore(); this.readiness.promise.catch(() => {}); previousReadiness.reject(error); for (const request of this.requests.values()) { if (request.pid && !request.childClosed) { const cleanup = terminateLostBrokerChild(request.pid, request.detached, this.closing ? brokerExited.promise : void 0); this.retainCleanup(cleanup); } request.result?.reject(error); request.child.fail(error); } this.requests.clear(); if (child.pid && process.platform !== "win32") this.retainCleanup(terminateBrokerProcessGroup(child.pid)); if (this.closing || !this.hasBeenReady) { this.readiness.reject(error); return; } const delay = RESTART_DELAYS[this.consecutiveFailures++]; if (delay === void 0) { this.readiness.reject(error); return; } this.restartTimer = setTimeout(() => this.start(), delay); }; child.once("error", fail); child.once("exit", (code, signal) => { brokerExited.resolve(); fail(/* @__PURE__ */ new Error(`exited with code=${code ?? "null"} signal=${signal ?? "none"}`)); }); child.once("disconnect", () => fail(/* @__PURE__ */ new Error("IPC channel disconnected"))); const abortTransport = (error) => { fail(error); if (child.connected) child.disconnect(); child.kill("SIGTERM"); }; child.on("message", (raw, handle) => { if (ended || this.closing) { if (handle instanceof Socket) handle.destroy(); return; } let decoded; try { decoded = receiver.receive(raw); } catch (error) { abortTransport(error instanceof Error ? error : new Error(String(error))); return; } if (decoded === void 0) return; const message = decoded; if (message.type === "ready") { clearTimeout(startupTimer); this.hasBeenReady = true; this.consecutiveFailures = 0; this.available = true; this.readiness.resolve(); this.options.onReady?.(message.pid, generation > 0); return; } const request = this.requests.get(message.id); if (!request) { if (handle instanceof Socket) handle.destroy(); if (message.type === "pipe") this.transmit({ type: "pipe-received", id: message.id, fd: message.fd }).catch(fail); return; } if (message.type === "owned") request.pid = message.pid; else if (message.type === "pipe") { try { if (message.closed && message.fd === 0) { const stdin = new Socket(); request.child.attachPipe(message.fd, stdin); stdin.destroy(); } else if (handle instanceof Socket) { if (message.fd === 0) restoreStdinPipe(handle); else holdPipe(handle); request.child.attachPipe(message.fd, handle); } else throw new SpawnBrokerError("Spawn broker pipe transfer failed"); } catch (error) { if (handle instanceof Socket) handle.destroy(); abortTransport(toErrorObject(error, "Spawn broker pipe setup failed")); return; } this.transmit({ type: "pipe-received", id: message.id, fd: message.fd }).catch(fail); } else if (message.type === "pipe-prefix") { const pipe = request.child.stdio[message.fd]; if (pipe instanceof Socket) restorePipePrefix(pipe, message.bytes); } else if (message.type === "execa-result") { if (request.pid === void 0 && message.result.failed) request.child.markNotStarted(); request.result?.resolve(message.result); request.resultSettled = true; this.retire(message.id, request); } else { if (message.type === "error" && message.resultUnavailable && request.result) { request.result.reject(Object.assign(new Error(message.error.message), message.error)); request.resultSettled = true; } request.child.receive(message); this.retire(message.id, request); } }); } /** Join cleanup already retained by this host, including recorded failures. */ async waitForCleanup() { await Promise.allSettled([...this.cleanups].map((cleanup) => cleanup.settled)); if (this.cleanupErrors.length) throw new AggregateError(this.cleanupErrors, "Spawn broker cleanup did not complete"); } close() { return this.closePromise ??= this.closeInternal(); } async closeInternal() { this.closing = true; this.readiness.reject(new SpawnBrokerError("Spawn broker is closing")); clearTimeout(this.restartTimer); try { const child = this.process; if (child && child.exitCode === null && child.signalCode === null) { const exited = new Promise((resolve) => { child.once("exit", () => resolve()); }); if (child.connected) child.disconnect(); const timer = setTimeout(() => child.kill("SIGKILL"), 7e3); try { await exited; } finally { clearTimeout(timer); } } await this.waitForCleanup(); } finally { process.removeListener("exit", this.onParentExit); } } }; function createSpawnBrokerHost(options) { return new SpawnBrokerHost(options); } //#endregion export { GRACEFUL_CANCEL_TIMEOUT_MS as a, spawnBrokerEntryPath as i, brokerSpawnOptions as n, createSpawnBrokerHost as r, SpawnBrokerHost as t };