import { spawn } from "child_process"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { mkdir } from "fs/promises"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import net from "net"; import { getPiAgentDir } from "../agent-dir.ts"; import { getBrokerSocketPath, getLegacyBrokerSocketPath, isOwnedBrokerSocket } from "./paths.ts"; import { isBrokerPidReused } from "./pid.ts"; import { errorMessage, hasErrorCode } from "../../shared/unknown.ts"; const INTERCOM_DIR = join(getPiAgentDir(), "intercom"); const EXTENSION_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const BROKER_PID = join(INTERCOM_DIR, "broker.pid"); const BROKER_SPAWN_LOCK = join(INTERCOM_DIR, "broker.spawn.lock"); function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function usesLegacyTsxDefault(brokerCommand: string, brokerArgs: readonly string[]): boolean { return ( brokerCommand === "npx" && brokerArgs.length === 2 && brokerArgs[0] === "--no-install" && brokerArgs[1] === "tsx" ); } export function getBrokerLaunchSpec( brokerPath: string, brokerCommand: string, brokerArgs: readonly string[], ): { command: string; args: string[] } { if (usesLegacyTsxDefault(brokerCommand, brokerArgs)) { return { command: process.execPath, args: [brokerPath], }; } return { command: brokerCommand, args: [...brokerArgs, brokerPath], }; } export function getBrokerSpawnOptions(): { detached: true; stdio: "ignore"; cwd: string; env: NodeJS.ProcessEnv; } { return { detached: true, stdio: "ignore", cwd: EXTENSION_DIR, env: { ...process.env, NODE_NO_WARNINGS: "1" }, }; } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(errorMessage(error)); } export async function spawnBrokerIfNeeded( brokerCommand: string, brokerArgs: readonly string[], ): Promise { await mkdir(INTERCOM_DIR, { recursive: true }); if (await isBrokerRunning()) { return; } const ownsLock = acquireSpawnLock(); if (!ownsLock) { await waitForBroker(); return; } try { if (await isBrokerRunning()) { return; } await stopUnhealthyBrokerBeforeSpawn(); const brokerPath = join( dirname(fileURLToPath(import.meta.url)), import.meta.url.endsWith(".ts") ? "broker.ts" : "broker.js", ); const launch = getBrokerLaunchSpec(brokerPath, brokerCommand, brokerArgs); const child = spawn(launch.command, launch.args, getBrokerSpawnOptions()); child.unref(); await new Promise((resolve, reject) => { const cleanup = () => { child.off("error", onError); child.off("exit", onExit); }; const onError = (error: Readonly) => { cleanup(); reject(new Error(`Failed to spawn intercom broker: ${error.message}`, { cause: error })); }; const onExit = (code: number | null, signal: NodeJS.Signals | null) => { cleanup(); if (signal !== null) { reject(new Error(`Intercom broker exited before startup with signal ${signal}`)); return; } reject(new Error(`Intercom broker exited before startup with code ${code ?? "unknown"}`)); }; child.once("error", onError); child.once("exit", onExit); waitForBroker() .then(() => { cleanup(); resolve(); }) .catch((error: unknown) => { cleanup(); reject(toError(error)); }); }); } finally { releaseSpawnLock(); } } export async function isBrokerRunning(): Promise { return checkSocketConnectable(); } function readLivePid(): number | null { if (!existsSync(BROKER_PID)) { return null; } let raw: string; try { raw = readFileSync(BROKER_PID, "utf-8"); } catch { return null; } const pid = parseInt(raw.trim(), 10); if (!Number.isFinite(pid) || pid <= 0) { return null; } try { process.kill(pid, 0); } catch (error) { return hasErrorCode(error, "EPERM") ? pid : null; } return isBrokerPidReused(pid, raw) ? null : pid; } export async function stopUnhealthyBrokerBeforeSpawn(): Promise { if (await checkSocketConnectable()) { return; } const pid = readLivePid(); if (pid === null) { return; } throw new Error( `Intercom broker PID ${pid} may still own accepted work but its socket is unhealthy; refusing to spawn a second broker. Let the broker exit normally, then retry.`, ); } async function checkSocketConnectable(): Promise { const preferred = getBrokerSocketPath(); if (isOwnedBrokerSocket(preferred) && (await checkOneSocket(preferred))) { return true; } const legacy = getLegacyBrokerSocketPath(); return legacy !== preferred && isOwnedBrokerSocket(legacy) && (await checkOneSocket(legacy)); } function checkOneSocket(brokerSocket: string): Promise { return new Promise((resolve) => { const socket = net.connect(brokerSocket); const finish = (isConnected: boolean) => { clearTimeout(timeout); socket.off("connect", onConnect); socket.off("error", onError); socket.destroy(); resolve(isConnected); }; const onConnect = () => finish(true); const onError = () => finish(false); socket.on("connect", onConnect); socket.on("error", onError); const timeout = setTimeout(() => finish(false), 1000); timeout.unref(); }); } function acquireSpawnLock(): boolean { const maxRetries = 5; for (let attempt = 0; attempt < maxRetries; attempt++) { try { writeFileSync(BROKER_SPAWN_LOCK, `${process.pid}\n${Date.now()}\n`, { flag: "wx" }); return true; } catch (error) { if (!hasErrorCode(error, "EEXIST")) { throw error; } if (isSpawnLockStale()) { releaseSpawnLock(); continue; } return false; } } return false; } function isSpawnLockStale(): boolean { if (!existsSync(BROKER_SPAWN_LOCK)) { return false; } try { const [pidLine = "", createdAtLine = "0"] = readFileSync(BROKER_SPAWN_LOCK, "utf-8") .trim() .split("\n"); const pid = Number.parseInt(pidLine, 10); const createdAt = Number.parseInt(createdAtLine, 10); const ageMs = Date.now() - createdAt; if (Number.isFinite(pid)) { try { process.kill(pid, 0); } catch { // The process that created the lock is gone. return true; } } return !Number.isFinite(createdAt) || ageMs > 10_000; } catch { // Unreadable lock contents are treated as stale so a new broker can start. return true; } } function releaseSpawnLock(): void { try { unlinkSync(BROKER_SPAWN_LOCK); } catch { // Another cleanup path may already have removed the lock. } } async function waitForBroker(timeoutMs = 5000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { // Each probe must finish before the next to avoid parallel socket attempts. // oxlint-disable-next-line no-await-in-loop if (await checkSocketConnectable()) { return; } // Polling waits between probes rather than starting an unbounded batch. // oxlint-disable-next-line no-await-in-loop await sleep(100); } throw new Error("Broker failed to start within timeout"); }