diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-06 23:15:08 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-06 23:15:08 +0530 |
| commit | 2ba584869909363705b9f1b7232cf2bd89ba9cff (patch) | |
| tree | 7d29ac75c9c32e8cb0bfc62aff2cf844c2988654 | |
| parent | 26daf87896d6645fe9be71460d2868755a4f939f (diff) | |
fix: stale socket detection prevents daemon spawn after crash
isDaemonRunning() only checked file existence of the Unix socket.
A dead daemon leaves a stale socket, causing ensureDaemon() to skip
spawning and client.start() to fail with 'Chrome not connected'.
Replace with a liveness probe: connect, register, verify ack, then
disconnect. On failure, clean up the stale socket.
Also makes bridge handleRequest async to poll for Chrome connection
for up to 15s before rejecting.
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/daemon/bridge.ts | 16 | ||||
| -rw-r--r-- | src/daemon/spawn.ts | 64 |
3 files changed, 72 insertions, 10 deletions
diff --git a/package.json b/package.json index ad081da..c82d38b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-browser-harness", - "version": "0.8.1", + "version": "0.8.2", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package", diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts index c2c1355..33ef63b 100644 --- a/src/daemon/bridge.ts +++ b/src/daemon/bridge.ts @@ -29,7 +29,7 @@ export type CdpBridge = { * are initialized (not when Chrome is connected — that may take retries). */ start(): Promise<void>; stop(): Promise<void>; - handleRequest(req: WireRequest, clientId: string, send: SendToClient): 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; @@ -339,11 +339,21 @@ export const createCdpBridge = (): CdpBridge => { wsUrl = null; }; - const handleRequest = ( + const handleRequest = async ( req: WireRequest, clientId: string, send: SendToClient, - ): void => { + ): 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) { + await new Promise((r) => setTimeout(r, 250)); + if (ws && ws.readyState === WebSocket.OPEN) break; + } + } + if (!ws || ws.readyState !== WebSocket.OPEN) { send(clientId, { type: "response", diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts index e9f4d51..0a2670c 100644 --- a/src/daemon/spawn.ts +++ b/src/daemon/spawn.ts @@ -10,25 +10,77 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { access } from "node:fs/promises"; +import { access, unlink } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { DAEMON_SOCKET_PATH } from "./protocol"; +import { createConnection } from "node:net"; +import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol"; const moduleDir = dirname(fileURLToPath(import.meta.url)); /** - * Check whether the daemon is running by testing if its Unix socket exists. - * The socket file is created by the daemon on startup and removed on shutdown. + * 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 (): Promise<boolean> => { +export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { + // If the socket file doesn't exist, the daemon definitely isn't running. try { await access(DAEMON_SOCKET_PATH); - return true; } catch { 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(); + // ponytail: stale socket cleanup; ENOENT = already gone, fine + unlink(DAEMON_SOCKET_PATH).catch(() => {}); + 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); + // 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(); + done(true); + } + }); + + sock.on("error", () => { + clearTimeout(timer); + sock.destroy(); + // ponytail: connection refused = daemon dead, clean up stale socket + unlink(DAEMON_SOCKET_PATH).catch(() => {}); + 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. + sock.on("close", () => { + clearTimeout(timer); + done(false); + }); + }); }; /** |
