diff options
| -rw-r--r-- | src/daemon/bridge.ts | 59 | ||||
| -rw-r--r-- | src/daemon/index.ts | 23 | ||||
| -rw-r--r-- | src/daemon/protocol.ts | 58 | ||||
| -rw-r--r-- | src/daemon/server.ts | 58 | ||||
| -rw-r--r-- | src/daemon/spawn.ts | 61 |
5 files changed, 13 insertions, 246 deletions
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts index 3f8b604..bc162a7 100644 --- a/src/daemon/bridge.ts +++ b/src/daemon/bridge.ts @@ -1,14 +1,3 @@ -/** - * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing. - * - * A single long-lived process connection to Chrome that proxies CDP traffic - * for multiple pi clients connected via the IpcServer. One Allow dialog ever. - * - * References: - * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals - * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions - * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/ - */ import WebSocket from "ws"; import { discoverWsUrl } from "../cdp/discovery"; @@ -18,7 +7,6 @@ import type { WireRequest, WireResponse, WireEvent } from "./protocol"; import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol"; import { asString, isRecord } from "../util/guards"; -// ── Types ────────────────────────────────────────────────────────────────────── export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void; @@ -27,12 +15,9 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void export type CloseHandler = () => void; export type CdpBridge = { - /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop - * are initialized (not when Chrome is connected — that may take retries). */ start(): Promise<void>; stop(): Promise<void>; handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>; - /** True when the Chrome WebSocket is open and ready. */ isAlive(): boolean; recordSession(clientId: string, sessionId: string): void; releaseSession(sessionId: string): void; @@ -42,7 +27,6 @@ export type CdpBridge = { onClose(handler: CloseHandler): void; }; -// ── ID Multiplexer ──────────────────────────────────────────────────────────── interface PendingEntry { clientId: string; @@ -79,11 +63,10 @@ class IdMultiplexer { } } -// ── Event Router ─────────────────────────────────────────────────────────────── class EventRouter { - private owners = new Map<string, string>(); // sessionId → clientId - private clientSessions = new Map<string, Set<string>>(); // clientId → sessions + private owners = new Map<string, string>(); + private clientSessions = new Map<string, Set<string>>(); record(clientId: string, sessionId: string): void { const prev = this.owners.get(sessionId); @@ -112,11 +95,6 @@ class EventRouter { this.clientSessions.delete(clientId); } - /** - * Route a CDP event to client(s). - * Session-scoped → only the owning client. - * Browser-level (no sessionId) → all connected clients. - */ route(event: WireEvent): string[] { if (event.sessionId) { const owner = this.owners.get(event.sessionId); @@ -130,7 +108,6 @@ class EventRouter { } } -// ── Implementation ───────────────────────────────────────────────────────────── export const createCdpBridge = (): CdpBridge => { let ws: WebSocket | null = null; @@ -140,7 +117,6 @@ export const createCdpBridge = (): CdpBridge => { let eventHandler: EventHandler | null = null; let closeHandler: CloseHandler | null = null; - // daemonId → { send, timer, isAttach } const callbacks = new Map<number, { send: SendToClient; clientId: string; @@ -149,7 +125,6 @@ export const createCdpBridge = (): CdpBridge => { isAttach: boolean; }>(); - // ── Handle raw CDP messages from Chrome ───────────────────────────────── const onChromeMessage = (raw: string): void => { let parsed: unknown; @@ -157,12 +132,9 @@ export const createCdpBridge = (): CdpBridge => { if (!isCdpRawMessage(parsed)) return; const msg: CdpRawMessage = parsed; - // ---- Response (has id) ---- if (msg.id !== undefined) { const cb = callbacks.get(msg.id); if (!cb) { - // Stale response — client already timed out or disconnected. - // Still clean up the multiplexer mapping. mux.resolve(msg.id); return; } @@ -178,7 +150,6 @@ export const createCdpBridge = (): CdpBridge => { error: { code: msg.error.code ?? -1, message: msg.error.message }, }); } else { - // Track session ownership for attach responses if (cb.isAttach && msg.result) { const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined; if (sessionId) { @@ -192,10 +163,8 @@ export const createCdpBridge = (): CdpBridge => { return; } - // ---- Event (no id, has method) ---- if (!msg.method) return; - // Inspector.detached — release session ownership if (msg.method === "Inspector.detached" && msg.sessionId) { router.release(msg.sessionId); } @@ -207,7 +176,6 @@ export const createCdpBridge = (): CdpBridge => { ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), }; - // Route through the event handler for the IpcServer to broadcast if (eventHandler) { const targets = router.route(wireEvent); if (targets.length > 0) { @@ -216,29 +184,19 @@ export const createCdpBridge = (): CdpBridge => { } }; - // ── Connect to Chrome ─────────────────────────────────────────────────── - // ── Auto-reconnecting Chrome connection ─────────────────────────────── let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectAttempt = 0; let stopped = false; - /** - * Try to discover Chrome and open a WebSocket. On failure, schedule - * an exponential backoff retry (capped at 60s). When Chrome finally - * becomes available, the bridge connects automatically — no manual - * daemon restart needed. - */ const tryConnect = (): void => { if (stopped) return; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } const attempt = async (): Promise<void> => { - // Already connected — nothing to do. if (ws && ws.readyState === WebSocket.OPEN) return; - // Resolve URL (discover if not cached, or clear cache on retry) const cachedUrl = wsUrl; let url: string; if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) { @@ -275,7 +233,6 @@ export const createCdpBridge = (): CdpBridge => { clearTimeout(timer); ws = sock; reconnectAttempt = 0; - // Enable target discovery so we see page events ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } })); console.log("[pi-browser-daemon] Connected to Chrome ✓"); settleOnce(); @@ -293,7 +250,6 @@ export const createCdpBridge = (): CdpBridge => { sock.on("close", () => { clearTimeout(timer); ws = null; - // Reject all pending callbacks for (const [daemonId, cb] of callbacks) { clearTimeout(cb.timer); cb.send(cb.clientId, { @@ -305,13 +261,11 @@ export const createCdpBridge = (): CdpBridge => { mux.resolve(daemonId); } closeHandler?.(); - // Auto-reconnect on unexpected close scheduleRetry(); }); }); await settledPromise; - // If connection didn't succeed, schedule a retry if (!ws || ws.readyState !== WebSocket.OPEN) { scheduleRetry(); } @@ -320,20 +274,16 @@ export const createCdpBridge = (): CdpBridge => { attempt().catch(() => scheduleRetry()); }; - /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */ const scheduleRetry = (): void => { if (stopped) return; const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000); reconnectAttempt++; - wsUrl = null; // force re-discovery on next attempt + wsUrl = null; reconnectTimer = setTimeout(tryConnect, delay); }; const start = async (): Promise<void> => { stopped = false; - // Don't await — the retry loop runs in the background. - // The daemon is "started" when the IPC server is listening, not when - // Chrome is connected. tryConnect(); }; @@ -351,8 +301,6 @@ export const createCdpBridge = (): CdpBridge => { clientId: string, send: SendToClient, ): Promise<void> => { - // ponytail: wait for Chrome to connect (user may still be clicking "Allow"). - // Bridge retries in background; this polls so the client doesn't fail first try. if (!ws || ws.readyState !== WebSocket.OPEN) { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { @@ -372,7 +320,6 @@ export const createCdpBridge = (): CdpBridge => { const daemonId = mux.allocate(clientId, req.id); - // Store callback for the response const timer = setTimeout(() => { callbacks.delete(daemonId); mux.resolve(daemonId); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index d2c43a8..30a6b6e 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -1,12 +1,3 @@ -/** - * Browser daemon entry point — standalone long-lived process. - * - * Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket - * for pi instances to connect through. One Allow dialog ever (per Chrome - * lifetime), zero repeated prompts. - * - * Run: npx tsx src/daemon/index.ts - */ import { createIpcServer } from "./server"; import { createCdpBridge, type SendToClient } from "./bridge"; @@ -18,9 +9,7 @@ async function main() { const ipcServer = createIpcServer(); const cdpBridge = createCdpBridge(); - // ── Wire IpcServer → CdpBridge ───────────────────────────────────────── - // When a pi client sends a CDP request, proxy it through the bridge to Chrome. ipcServer.onMessage((msg, client) => { if (msg.type !== "request") return; @@ -31,19 +20,16 @@ async function main() { cdpBridge.handleRequest(msg, client.id, send); }); - // When Chrome sends an event, route it to the correct pi client(s). cdpBridge.onEvent((event, targetClientIds) => { for (const cid of targetClientIds) { ipcServer.send(cid, event); } }); - // When a client disconnects, clean up its CDP state. ipcServer.onDisconnect((client) => { cdpBridge.removeClient(client.id); }); - // When Chrome disconnects, notify all clients. cdpBridge.onClose(() => { console.log("[pi-browser-daemon] Chrome disconnected"); ipcServer.broadcast({ @@ -53,11 +39,7 @@ async function main() { }); }); - // ── Idle timeout ─────────────────────────────────────────────────────── - // The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients. - // This prevents zombie processes when pi sessions end and never reconnect. - // The timeout resets whenever a client connects. let idleTimer: ReturnType<typeof setTimeout> | null = null; const resetIdleTimer = (): void => { @@ -78,7 +60,6 @@ async function main() { ipcServer.onConnect(() => { cancelIdleTimer(); }); ipcServer.onDisconnect(() => { resetIdleTimer(); }); - // ── Start ────────────────────────────────────────────────────────────── try { await ipcServer.start(); @@ -88,14 +69,10 @@ async function main() { process.exit(1); } - // Connect to Chrome (auto-retries with backoff in the background). - // The daemon is ready immediately — Chrome may connect later. await cdpBridge.start(); - // Start the idle timer (no clients yet at startup). resetIdleTimer(); - // ── Graceful shutdown ────────────────────────────────────────────────── const shutdown = async () => { console.log("[pi-browser-daemon] Shutting down..."); diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 997a6c8..d3d8297 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -1,34 +1,17 @@ -/** - * Unix socket daemon protocol — wire message types, type guards, and constants. - * - * The daemon speaks JSON-line protocol over a Unix domain socket. - * One JSON object per newline. Four message types: request, response, event, control. - * - * References: - * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals - * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback - * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line - */ import { isRecord } from "../util/guards"; -// ── Message types ────────────────────────────────────────────────────────────── -/** A CDP command sent from a pi client to the daemon. */ export type WireRequest = { readonly type: "request"; - /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */ readonly id: number; readonly method: string; readonly params?: Record<string, unknown>; - /** Optional CDP session id. Omitted for browser-level commands. */ readonly sessionId?: string; }; -/** A CDP response (result or error) sent from the daemon back to the pi client. */ export type WireResponse = { readonly type: "response"; - /** Matches the id from the corresponding WireRequest. */ readonly id: number; readonly result?: unknown; readonly error?: { @@ -38,34 +21,23 @@ export type WireResponse = { }; }; -/** A CDP event forwarded from Chrome through the daemon to pi client(s). */ export type WireEvent = { readonly type: "event"; readonly method: string; readonly params?: Record<string, unknown>; - /** Session-scoped events carry this; browser-level events omit it. */ readonly sessionId?: string; }; -/** Daemon lifecycle control messages. Bidirectional. */ export type WireControl = { readonly type: "control"; readonly action: "register" | "registered" | "deregister" | "shutdown"; - /** pi client namespace (required for register/registered/deregister). */ readonly clientId?: string; - /** Human-readable reason for shutdown. */ readonly reason?: string; }; -/** All messages that may appear on the Unix socket. */ export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl; -// ── Type guard ───────────────────────────────────────────────────────────────── -/** - * Validates that an unknown parsed-JSON value is a well-formed WireMessage. - * Does NOT validate the full CDP semantics — only structural shape. - */ export const isWireMessage = (v: unknown): v is WireMessage => { if (!isRecord(v)) return false; const t = v["type"]; @@ -113,12 +85,9 @@ export const isWireMessage = (v: unknown): v is WireMessage => { } }; -// ── Wire message constructors ────────────────────────────────────────────────── -/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */ export const serialize = (msg: WireMessage): string => JSON.stringify(msg); -/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */ export const deserialize = (line: string): WireMessage | null => { let parsed: unknown; try { @@ -130,42 +99,19 @@ export const deserialize = (line: string): WireMessage | null => { return parsed; }; -// ── Constants ────────────────────────────────────────────────────────────────── -/** - * IPC endpoint for the daemon. - * - * On POSIX platforms this is a Unix domain socket path. On Windows, Node's IPC - * requires a named pipe of the form `\\.\pipe\<name>` — a Unix path like - * `/tmp/…` is not a valid `net` listen/connect target there and causes setup - * to fail. See https://nodejs.org/api/net.html#ipc-support. - */ +// Windows requires a named pipe (`\\.\pipe\<name>`); a Unix path is not a valid `net` listen/connect target there. export const DAEMON_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\pi-browser-daemon" : "/tmp/pi-browser-daemon.sock"; -/** - * How long the daemon stays alive with zero connected clients before exiting - * (milliseconds). After this period, the daemon cleans up and shuts down. - */ -export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; -/** - * Hard cap on concurrent pi client connections. Extra connections are rejected - * immediately to prevent resource exhaustion. - */ export const DAEMON_MAX_CLIENTS = 16; -/** - * If true, the daemon unlinks (deletes) any stale socket file from a previous - * daemon crash before calling server.listen(). Without this, a crashed daemon - * leaves a socket inode that blocks the next listen() call with EADDRINUSE. - */ export const DAEMON_STALE_SOCKET_CLEANUP = true; -/** CDP WebSocket connection timeout (ms). */ export const CDP_CONNECT_TIMEOUT_MS = 10_000; -/** CDP command timeout when proxying through daemon (ms). */ export const CDP_COMMAND_TIMEOUT_MS = 10_000; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index fea9bb7..b694fc0 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -1,15 +1,3 @@ -/** - * IpcServer — Unix socket server for daemon ↔ pi client communication. - * - * Accepts multiple concurrent pi client connections over a Unix domain - * stream socket. Uses newline-delimited JSON framing (one WireMessage per - * line). Clients are anonymous until they send a control.register message. - * - * References: - * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener - * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback - * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions - */ import { createServer, type Server, type Socket } from "node:net"; import { createInterface, type Interface } from "node:readline"; @@ -23,54 +11,35 @@ import { DAEMON_STALE_SOCKET_CLEANUP, } from "./protocol"; -// ── Types ────────────────────────────────────────────────────────────────────── -/** A connected pi client with its socket and readline interface. */ export type ClientSocket = { - /** Client namespace (set after registration). */ id: string; readonly socket: Socket; readonly rl: Interface; - /** True once a valid control.register message has been received. */ registered: boolean; }; -/** Handler called for every valid WireMessage from any client. */ export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void; -/** Handler called when a client connects or disconnects. */ export type ConnectionHandler = (client: ClientSocket) => void; export type IpcServer = { - /** Start listening on the Unix socket. Resolves when the server is ready. */ start(): Promise<void>; - /** Close the server and all client connections. Resolves when cleaned up. */ stop(): Promise<void>; - /** Register a handler for incoming WireMessages from any client. */ onMessage(handler: MessageHandler): void; - /** Register a handler for client connect events. */ onConnect(handler: ConnectionHandler): void; - /** Register a handler for client disconnect events. */ onDisconnect(handler: ConnectionHandler): void; - /** Send a WireMessage to a specific client by clientId. No-op if client not found. */ send(clientId: string, msg: WireMessage): void; - /** Send a WireMessage to all connected (registered) clients. */ broadcast(msg: WireMessage): void; - /** Force-disconnect a client by clientId. */ disconnectClient(clientId: string): void; - /** All currently connected clients (by clientId). */ clients(): ReadonlyMap<string, ClientSocket>; - /** Number of connected clients. */ clientCount(): number; }; -// ── Implementation ───────────────────────────────────────────────────────────── export const createIpcServer = (): IpcServer => { let server: Server | null = null; - // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration. const sockets = new Map<string, ClientSocket>(); - // clientId → ClientSocket (only registered clients). const clients = new Map<string, ClientSocket>(); let messageHandler: MessageHandler | null = null; let connectHandler: ConnectionHandler | null = null; @@ -79,17 +48,14 @@ export const createIpcServer = (): IpcServer => { const start = (): Promise<void> => new Promise((resolve, reject) => { - // Clean up stale socket file from a previous crashed daemon. if (DAEMON_STALE_SOCKET_CLEANUP) { try { unlinkSync(DAEMON_SOCKET_PATH); } catch { - // ENOENT is expected — no stale socket to clean. } } server = createServer({ pauseOnConnect: false }, (socket: Socket) => { - // Reject if at capacity (check registered clients only). if (clients.size >= DAEMON_MAX_CLIENTS) { socket.destroy(); return; @@ -101,8 +67,6 @@ export const createIpcServer = (): IpcServer => { sockets.set(tempId, currentClient); - // Buffer for partial lines. readline handles line splitting, but - // we store a reference in case we need it for diagnostics. let lineCount = 0; rl.on("line", (line: string) => { @@ -113,16 +77,13 @@ export const createIpcServer = (): IpcServer => { if (!isWireMessage(parsed)) return; msg = parsed; } catch { - // Malformed JSON — ignore silently, don't crash the daemon. return; } - // Handle registration before forwarding to the general handler. if (msg.type === "control" && msg.action === "register" && !currentClient.registered) { const clientId = msg.clientId; if (!clientId) return; - // If a client with this id is already connected, reject. if (clients.has(clientId)) { const reply: WireControl = { type: "control", @@ -134,14 +95,12 @@ export const createIpcServer = (): IpcServer => { return; } - // Promote from anonymous to registered — mutate in place so the - // closure captures the updated client reference. + // Mutate in place so the already-captured closure sees the promoted client. sockets.delete(tempId); currentClient.id = clientId; currentClient.registered = true; clients.set(clientId, currentClient); - // Acknowledge registration. const ack: WireControl = { type: "control", action: "registered", clientId }; socket.write(JSON.stringify(ack) + "\n"); @@ -149,29 +108,23 @@ export const createIpcServer = (): IpcServer => { return; } - // Require registration for non-control messages. if (!currentClient.registered) return; - // Forward to the general handler. messageHandler?.(msg, currentClient); }); socket.on("error", () => { - // Socket errors (ECONNRESET, etc.) are handled by the 'close' event. }); socket.on("close", () => { - // Clean up whichever map the client is in. const found = clients.get(currentClient.id) ?? sockets.get(tempId); if (found) { clients.delete(found.id); sockets.delete(tempId); - // Notify if this was a registered client. if (found.registered) { disconnectHandler?.(found); } } - // Clean up readline to prevent memory leaks. rl.close(); }); }); @@ -187,20 +140,18 @@ export const createIpcServer = (): IpcServer => { const stop = (): Promise<void> => new Promise((resolve) => { - // Close all client sockets. for (const [, client] of clients) { - try { client.socket.destroy(); } catch { /* best-effort */ } + try { client.socket.destroy(); } catch {} } for (const [, client] of sockets) { - try { client.socket.destroy(); } catch { /* best-effort */ } + try { client.socket.destroy(); } catch {} } clients.clear(); sockets.clear(); if (server) { server.close(() => { - // Clean up the socket file. - try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ } + try { unlinkSync(DAEMON_SOCKET_PATH); } catch {} resolve(); }); } else { @@ -226,7 +177,6 @@ export const createIpcServer = (): IpcServer => { try { client.socket.write(JSON.stringify(msg) + "\n"); } catch { - // Socket write error — the close handler will clean up. } }, broadcast(msg) { diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts index 63c0dab..d14d234 100644 --- a/src/daemon/spawn.ts +++ b/src/daemon/spawn.ts @@ -1,13 +1,3 @@ -/** - * Daemon lifecycle helpers — spawn detection and lazy launch. - * - * The daemon is a standalone long-lived process that owns the single WebSocket - * to Chrome. pi instances connect to it via a Unix domain socket. This module - * provides the plumbing to detect a running daemon and spawn one if needed. - * - * Reference: - * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached - */ import { spawn, type ChildProcess } from "node:child_process"; import { access, unlink } from "node:fs/promises"; @@ -21,12 +11,7 @@ const moduleDir = dirname(fileURLToPath(import.meta.url)); const isWindows = process.platform === "win32"; -/** - * Remove a stale POSIX socket file. On Windows the daemon endpoint is a named - * pipe, which is not a filesystem entry and is torn down automatically when the - * owning process exits — so there is nothing to unlink, and calling unlink on a - * pipe path throws. This is a no-op on Windows. - */ +// 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(() => {}); @@ -45,12 +30,6 @@ export const daemonSocketMayExist = async ( } }; -/** - * Check whether the daemon is running by probing its Unix socket. - * A file-existence check is unreliable — a crashed daemon leaves a stale socket. - * We connect, send a register, wait for the registered ack, then disconnect. - * On failure, clean up the stale socket so ensureDaemon can spawn a fresh one. - */ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { if (!(await daemonSocketMayExist())) return false; @@ -65,7 +44,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { const sock = createConnection(DAEMON_SOCKET_PATH); const timer = setTimeout(() => { sock.destroy(); - // ponytail: stale socket cleanup; ENOENT = already gone, fine cleanupStaleSocket(); done(false); }, timeoutMs); @@ -79,7 +57,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { const text = chunk.toString(); if (text.includes('"registered"')) { clearTimeout(timer); - // Send deregister so the probe doesn't hog a slot const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" }; try { sock.write(serialize(deregMsg) + "\n"); } catch {} sock.destroy(); @@ -90,13 +67,11 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { sock.on("error", () => { clearTimeout(timer); sock.destroy(); - // ponytail: connection refused = daemon dead, clean up stale socket cleanupStaleSocket(); done(false); }); - // close without 'registered' ack = daemon alive but rejected us (e.g. max clients); - // don't unlink — the daemon is still using that socket. + // 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); @@ -104,45 +79,24 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { }); }; -/** - * Spawn the daemon as a detached child process. The daemon will continue - * running after the parent pi process exits. - * - * The daemon entry point is resolved relative to this module. When running - * from source (tsx), the entry is src/daemon/index.ts. When running compiled, - * it's dist/daemon/index.js. - * - * Returns the child process handle (caller should call child.unref()). - */ export const spawnDaemon = (): ChildProcess | null => { - // Resolve the daemon entry point and tsx binary. const daemonScript = join(moduleDir, "index.ts"); - // On Windows, npm installs binaries as `.cmd` shims, not bare executables, - // and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched - // through a shell (spawning it directly yields EINVAL), so set shell: true - // whenever we invoke one. + // Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL. const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx"); const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase; const hasLocalTsx = existsSync(tsxBin); - // Use the local tsx binary to run the daemon TypeScript source directly. - // The spawned process inherits the parent's env so module resolution works. - // Fall back to `npx tsx` if the local binary isn't available. const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx"; const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript]; - // With shell: true, Node passes the command line to the shell verbatim - // without quoting, so any path containing spaces (e.g. C:\Users\Some Name) - // would be split into multiple tokens. Wrap each token in double quotes on - // Windows to keep space-containing paths intact. + // With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens. const quote = (s: string): string => (isWindows ? `"${s}"` : s); const child = spawn(quote(cmd), args.map(quote), { detached: true, stdio: "ignore", env: { ...process.env }, - // `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell. shell: isWindows, windowsVerbatimArguments: isWindows, }); @@ -151,19 +105,12 @@ export const spawnDaemon = (): ChildProcess | null => { return child; }; -/** - * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs) - * for the socket file to appear. - * - * Returns true if the daemon is running (or was successfully spawned). - */ export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => { if (await isDaemonRunning()) return true; const child = spawnDaemon(); if (!child) return false; - // Poll for the socket file to appear const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isDaemonRunning()) return true; |
