all repos — sz-chrome-remote-cdp @ 2ba584869909363705b9f1b7232cf2bd89ba9cff

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

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.
amankumarsingh77 amankumarsingh7702@gmail.com
Mon, 06 Jul 2026 23:15:08 +0530
commit

2ba584869909363705b9f1b7232cf2bd89ba9cff

parent

26daf87896d6645fe9be71460d2868755a4f939f

3 files changed, 72 insertions(+), 10 deletions(-)

jump to
M package.json → 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",
M src/daemon/bridge.ts → src/daemon/bridge.ts

@@ -29,7 +29,7 @@ /** 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): 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 @@ if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }

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",
M src/daemon/spawn.ts → src/daemon/spawn.ts

@@ -10,25 +10,77 @@ * - 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 } 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); + }); + }); }; /**