import { spawn, type ChildProcess } from "node:child_process"; import { accessSync } from "node:fs"; import { access, unlink } from "node:fs/promises"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { createConnection } from "node:net"; import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol"; const moduleDir = dirname(fileURLToPath(import.meta.url)); const requireFromHere = createRequire(import.meta.url); const isWindows = process.platform === "win32"; // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws. const cleanupStaleSocket = (): void => { if (isWindows) return; unlink(DAEMON_SOCKET_PATH).catch(() => {}); }; export const daemonSocketMayExist = async ( platform: NodeJS.Platform = process.platform, socketPath: string = DAEMON_SOCKET_PATH, ): Promise => { if (platform === "win32") return true; try { await access(socketPath); return true; } catch { return false; } }; /** * FORK: ask a running daemon to exit, and wait until it has. * * Needed by /browser-target. The CDP target is resolved per daemon process and * the daemon holds an open WebSocket to the old browser, so switching browsers * means replacing the process — re-reading the stored target would not drop * that connection. * * Resolves true once the daemon is gone (or was never running). Resolves false * if it is still answering after the timeout, so callers can report the failure * instead of respawning into a port clash. */ export const stopDaemon = async (timeoutMs = 5_000): Promise => { if (!(await isDaemonRunning(1_500))) { cleanupStaleSocket(); return true; } await new Promise((resolve) => { let settled = false; const done = (): void => { if (settled) return; settled = true; resolve(); }; const sock = createConnection(DAEMON_SOCKET_PATH); const timer = setTimeout(() => { sock.destroy(); done(); }, timeoutMs); sock.on("connect", () => { // Registration is required before the daemon forwards anything to its // message handler, so the shutdown has to come from a registered client. const reg: WireControl = { type: "control", action: "register", clientId: "_target_switch" }; sock.write(serialize(reg) + "\n"); }); sock.on("data", (chunk: Buffer) => { if (!chunk.toString().includes('"registered"')) return; const bye: WireControl = { type: "control", action: "shutdown", reason: "browser target changed" }; try { sock.write(serialize(bye) + "\n"); } catch { // Daemon may already be going away; the poll below is the real check. } clearTimeout(timer); sock.destroy(); done(); }); sock.on("error", () => { clearTimeout(timer); done(); }); }); // The daemon exits asynchronously, so confirm rather than assume. const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (!(await isDaemonRunning(1_000))) { cleanupStaleSocket(); return true; } await new Promise((r) => setTimeout(r, 200)); } return false; }; export const isDaemonRunning = async (timeoutMs = 2_000): Promise => { if (!(await daemonSocketMayExist())) return false; return new Promise((resolve) => { let settled = false; const done = (alive: boolean): void => { if (settled) return; settled = true; resolve(alive); }; const sock = createConnection(DAEMON_SOCKET_PATH); const timer = setTimeout(() => { sock.destroy(); cleanupStaleSocket(); done(false); }, timeoutMs); sock.on("connect", () => { const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" }; sock.write(serialize(regMsg) + "\n"); }); sock.on("data", (chunk: Buffer) => { const text = chunk.toString(); if (text.includes('"registered"')) { clearTimeout(timer); const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" }; try { sock.write(serialize(deregMsg) + "\n"); } catch {} sock.destroy(); done(true); } }); sock.on("error", () => { clearTimeout(timer); sock.destroy(); cleanupStaleSocket(); done(false); }); // Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket. sock.on("close", () => { clearTimeout(timer); done(false); }); }); }; // Walks upward like Node's own node_modules lookup. Exported with an injectable start so the // hoisted layout pi uses can be exercised against a fixture instead of this checkout. export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => { let dir = startDir; while (true) { const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs"); try { accessSync(candidate); return candidate; } catch { // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup // without relying on createRequire().resolve(), which is broken for external modules // loaded through jiti/static inside a Bun-compiled executable such as pi.exe. } const parent = dirname(dir); if (parent === dir) return null; dir = parent; } }; const resolveTsxCli = (): string | null => { try { const resolved = requireFromHere.resolve("tsx/cli"); // A compiled runtime may theoretically return a virtual path that a separately spawned // Node process cannot read. Only accept a resolver result that exists on the host FS. accessSync(resolved); return resolved; } catch { return resolveTsxCliFromFilesystem(); } }; export const daemonNodeExecutable = ( env: NodeJS.ProcessEnv = process.env, versions: Readonly> = process.versions, platform: NodeJS.Platform = process.platform, execPath: string = process.execPath, ): string => { const override = env["PI_BROWSER_NODE"]; if (override) return override; // Under normal Node keep the exact executable that launched pi/the extension. Under Bun, // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime. if ("bun" in versions) return platform === "win32" ? "node.exe" : "node"; return execPath; }; export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray } | null => { const daemonScript = join(moduleDir, "index.ts"); const tsxCli = resolveTsxCli(); if (!tsxCli) return null; return { command: daemonNodeExecutable(), args: [tsxCli, daemonScript], }; }; export const spawnDaemon = (): ChildProcess | null => { const spec = daemonSpawnCommand(); if (!spec) return null; const child = spawn(spec.command, [...spec.args], { detached: true, stdio: "ignore", env: { ...process.env }, windowsHide: true, }); // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host) // asynchronously via `error`; without a listener that is an uncaught exception in pi. // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees. child.on("error", () => {}); child.unref(); return child; }; export const ensureDaemon = async (timeoutMs = 10_000): Promise => { if (await isDaemonRunning()) return true; const child = spawnDaemon(); if (!child) return false; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isDaemonRunning()) return true; await new Promise((r) => setTimeout(r, 200)); } return false; };