aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorUttkarsh <38376852+uttkarsh-26@users.noreply.github.com>2026-08-06 00:20:49 +0530
committerGitHub <noreply@github.com>2026-08-06 00:20:49 +0530
commitd9bc46b508f8c6f7e0f0ff5d54d804340f3e6c80 (patch)
tree0ea1315ccc0feaee1ddf184ed16f16173455b3b4
parent31c1b27a50bc2b9ba7138ae67a4cfaba6945582e (diff)
fix: reconnect to Chrome only on explicit browser demand (#18)
* fix: reconnect to Chrome only on browser demand * fix(daemon): gate stale CDP bridge reconnect attempts * Harden bridge reconnect lifecycle against stale socket callbacks * fix(daemon): handle stale attempt failures without deferred settlement * test: align waitFor assertions and stale-socket case labels * fix(daemon): re-check socket after connect wait in handleRequest Restores the post-wait re-check from the original PR branch (dropped during the main rebase merge); TypeScript needs the local narrowing before send. * fix(daemon): reject in-flight requests when an open connection drops `failCurrentAttempt` delegated callback rejection to `settleAttempt`, but an attempt that reached `open` had already settled, so the close path returned early and never answered its in-flight requests. They hung until the 10s command timeout instead of failing immediately, which the on-demand reconnect change had promised to preserve. Reject the attempt's callbacks directly in `failCurrentAttempt`, before the close handler notifies clients. Move the connection-lifecycle tests out of test/manual/ — which nothing runs — into test/daemon/, where `npm test` picks them up, and convert the hand-rolled pass/fail counters to node:test. Adds a case covering the drop path above; it fails without the fix. --------- Co-authored-by: Hermes Subagent <hermes-subagent@example.com> Co-authored-by: Hermes Agent <agent@example.com> Co-authored-by: uttkarsh-26 <uttkarsh@users.noreply.github.com> Co-authored-by: amankumarsingh77 <amankumarsingh7702@gmail.com>
-rw-r--r--CHANGELOG.md6
-rw-r--r--src/daemon/bridge.ts361
-rw-r--r--test/daemon/bridge-connection-test.ts616
3 files changed, 884 insertions, 99 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe95069..988e5bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable changes to pi-browser-harness will be documented in this file.
+## Unreleased
+
+### Fixed
+
+- **Chrome remote-debugging consent is no longer retriggered indefinitely in the background.** A cancelled, failed, or disconnected CDP connection now remains disconnected until the next explicit browser request, which makes one on-demand connection attempt instead of running an unbounded retry loop that can repeatedly interrupt normal browsing.
+
## 0.11.0 — 2026-08-02
### Fixed
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts
index 6503aa4..dc10f2b 100644
--- a/src/daemon/bridge.ts
+++ b/src/daemon/bridge.ts
@@ -5,6 +5,8 @@ import type { CdpRawMessage } from "../cdp/types";
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
import { asString, isRecord } from "../util/guards";
+import type { CdpError } from "../cdp/errors";
+import type { Result } from "../util/result";
export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
@@ -117,9 +119,22 @@ export const createEventRouter = (): EventRouter => {
};
};
-const CONNECT_WAIT_MS = 15_000;
+type CdpBridgeDependencies = {
+ discoverWsUrl?: () => Promise<Result<string, CdpError>>;
+ createWebSocket?: (url: string) => WebSocket;
+ connectTimeoutMs?: number;
+ onAttemptSettled?: () => void;
+};
+
+export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge => {
+ const {
+ discoverWsUrl: discoverWsUrlImpl = discoverWsUrl,
+ createWebSocket: createWebSocketImpl = (url: string): WebSocket =>
+ new WebSocket(url, { perMessageDeflate: false }),
+ connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
+ onAttemptSettled,
+ } = options;
-export const createCdpBridge = (): CdpBridge => {
let ws: WebSocket | null = null;
let wsUrl: string | null = null;
const mux = createIdMultiplexer();
@@ -127,23 +142,15 @@ export const createCdpBridge = (): CdpBridge => {
let eventHandler: EventHandler | null = null;
let closeHandler: CloseHandler | null = null;
- const connectedWaiters: Array<() => void> = [];
- const signalConnected = (): void => {
- for (const w of connectedWaiters.splice(0)) w();
- };
+ // daemonId -> { generation, clientId }: lets a failed attempt reject only its own
+ // in-flight requests, and a gone client's entries be dropped without late answers.
+ const generations = new Map<number, { generation: number; clientId: string }>();
- const waitForConnection = (timeoutMs: number): Promise<void> =>
- new Promise((resolve) => {
- let settled = false;
- const finish = (): void => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- resolve();
- };
- const timer = setTimeout(finish, timeoutMs);
- connectedWaiters.push(finish);
- });
+ let stopped = false;
+ let connecting = false;
+ let attemptGeneration = 0;
+ let activeAttempt = 0;
+ let activeSocket: WebSocket | null = null;
const onChromeMessage = (raw: string): void => {
let parsed: unknown;
@@ -152,6 +159,7 @@ export const createCdpBridge = (): CdpBridge => {
const msg: CdpRawMessage = parsed;
if (msg.id !== undefined) {
+ generations.delete(msg.id);
const cb = mux.take(msg.id);
if (!cb) return;
const localId = cb.localId;
@@ -195,98 +203,215 @@ export const createCdpBridge = (): CdpBridge => {
}
};
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- let reconnectAttempt = 0;
- let stopped = false;
+ const rejectCallbacksWhere = (
+ pred: (info: { generation: number; clientId: string }) => boolean,
+ ): void => {
+ for (const [daemonId, info] of generations) {
+ if (!pred(info)) continue;
+ generations.delete(daemonId);
+ const cb = mux.take(daemonId);
+ if (!cb) continue;
+ cb.send(cb.clientId, {
+ type: "response",
+ id: cb.localId,
+ error: { code: -32000, message: "Chrome disconnected" },
+ });
+ }
+ };
+
+ const rejectAttemptCallbacks = (generation: number): void => {
+ rejectCallbacksWhere((info) => info.generation === generation);
+ };
+
+ const rejectAllPendingCallbacks = (): void => {
+ rejectCallbacksWhere(() => true);
+ };
+
+ const closeSocket = (socket: WebSocket): void => {
+ try {
+ socket.close();
+ } catch {}
+ };
+
+ const isCurrentAttempt = (generation: number): boolean =>
+ generation === activeAttempt && !stopped;
+
+ const isCurrentAttemptSocket = (generation: number, socket: WebSocket): boolean =>
+ isCurrentAttempt(generation) && activeSocket === socket;
+
+ const clearActiveAttempt = (generation: number): void => {
+ if (activeAttempt === generation) {
+ activeAttempt = 0;
+ activeSocket = null;
+ }
+ };
+ // On-demand Chrome connection. Attempts happen only from start() and from
+ // handleRequest() while disconnected; a failed or disconnected bridge stays
+ // disconnected until the next explicit browser demand. No background retry loop.
const tryConnect = (): void => {
- if (stopped) return;
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
+ if (stopped || connecting || (ws && ws.readyState === WebSocket.OPEN)) return;
- const attempt = async (): Promise<void> => {
- if (ws && ws.readyState === WebSocket.OPEN) return;
+ const generation = ++attemptGeneration;
+ activeAttempt = generation;
+ connecting = true;
- const cachedUrl = wsUrl;
- let url: string;
- if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) {
- const d = await discoverWsUrl();
- if (!d.success) { scheduleRetry(); return; }
- url = d.data;
- wsUrl = url;
- } else {
- url = cachedUrl;
- }
+ const attempt = async (): Promise<void> => {
+ let settled = false;
+ let sock: WebSocket | null = null;
+ let timeout: ReturnType<typeof setTimeout> | null = null;
+ let settleAttempt: () => void = () => {};
- const settledPromise = new Promise<void>((settle) => {
- let settled = false;
- const settleOnce = (): void => {
+ const settledPromise = new Promise<void>((resolve) => {
+ settleAttempt = (): void => {
if (settled) return;
settled = true;
- settle();
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ if (generation === activeAttempt && !stopped) {
+ wsUrl = null;
+ connecting = false;
+ }
+ rejectAttemptCallbacks(generation);
+ onAttemptSettled?.();
+ resolve();
};
+ });
- let sock: WebSocket;
- try {
- sock = new WebSocket(url, { perMessageDeflate: false });
- } catch {
- scheduleRetry();
+ const clearAttemptTimeout = (): void => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = null;
+ }
+ };
+
+ const failCurrentAttempt = (notifyClose: boolean): void => {
+ clearAttemptTimeout();
+ const isCurrent = isCurrentAttempt(generation);
+ if (!isCurrent) {
+ rejectAttemptCallbacks(generation);
return;
}
- const timer = setTimeout(() => {
- sock.close();
- settleOnce();
- }, CDP_CONNECT_TIMEOUT_MS);
+ clearActiveAttempt(generation);
+ if (ws === sock) ws = null;
+ wsUrl = null;
+ connecting = false;
+ // Not via settleAttempt: a connection that opened already settled its attempt, so
+ // its requests would otherwise hang to the command timeout instead of failing now.
+ rejectAttemptCallbacks(generation);
+ if (notifyClose) closeHandler?.();
+ settleAttempt();
+ };
- sock.on("open", () => {
- clearTimeout(timer);
- ws = sock;
- reconnectAttempt = 0;
- ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
- console.log("[pi-browser-daemon] Connected to Chrome ✓");
- signalConnected();
- settleOnce();
- });
+ timeout = setTimeout(() => {
+ if (!isCurrentAttempt(generation) || stopped) {
+ failCurrentAttempt(false);
+ return;
+ }
- sock.on("message", (data: WebSocket.Data) => {
- onChromeMessage(typeof data === "string" ? data : data.toString());
- });
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
+ closeSocket(sock);
+ }
+ failCurrentAttempt(true);
+ }, connectTimeoutMs);
+
+ let url = wsUrl;
+ if (!url) {
+ const d = await discoverWsUrlImpl();
+ if (!isCurrentAttempt(generation) || stopped) {
+ failCurrentAttempt(false);
+ return;
+ }
+ if (!d.success) {
+ failCurrentAttempt(false);
+ return;
+ }
+ url = d.data;
+ }
- sock.on("error", () => {
- clearTimeout(timer);
- settleOnce();
- });
+ if (!isCurrentAttempt(generation) || stopped) {
+ failCurrentAttempt(false);
+ return;
+ }
- sock.on("close", () => {
- clearTimeout(timer);
- ws = null;
- for (const cb of mux.takeAll()) {
- cb.send(cb.clientId, {
- type: "response",
- id: cb.localId,
- error: { code: -32000, message: "Chrome disconnected" },
- });
+ try {
+ sock = createWebSocketImpl(url);
+ } catch {
+ failCurrentAttempt(false);
+ return;
+ }
+
+ if (!isCurrentAttempt(generation) || stopped) {
+ closeSocket(sock);
+ failCurrentAttempt(false);
+ return;
+ }
+
+ wsUrl = url;
+ activeSocket = sock;
+
+ const onOpen = (): void => {
+ if (!sock || !isCurrentAttemptSocket(generation, sock)) {
+ clearAttemptTimeout();
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
+ closeSocket(sock);
}
- closeHandler?.();
- scheduleRetry();
- });
- });
+ failCurrentAttempt(false);
+ return;
+ }
+
+ clearAttemptTimeout();
+ ws = sock;
+ try {
+ ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
+ } catch {
+ failCurrentAttempt(true);
+ if (sock.readyState !== WebSocket.CLOSED) {
+ closeSocket(sock);
+ }
+ return;
+ }
+ console.log("[pi-browser-daemon] Connected to Chrome ✓");
+ settleAttempt();
+ };
+ const onMessage = (data: WebSocket.Data): void => {
+ if (!sock || !isCurrentAttemptSocket(generation, sock)) return;
+ onChromeMessage(typeof data === "string" ? data : data.toString());
+ };
+
+ const onError = (): void => {
+ clearAttemptTimeout();
+ const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
+ failCurrentAttempt(notifyClose);
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
+ closeSocket(sock);
+ }
+ };
+
+ const onClose = (): void => {
+ clearAttemptTimeout();
+ const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
+ failCurrentAttempt(notifyClose);
+ };
+
+ sock.on("open", onOpen);
+ sock.on("message", onMessage);
+ sock.on("error", onError);
+ sock.on("close", onClose);
+
+ // The promise must always settle even on constructor errors.
await settledPromise;
- if (!ws || ws.readyState !== WebSocket.OPEN) {
- scheduleRetry();
- }
};
- attempt().catch(() => scheduleRetry());
- };
-
- const scheduleRetry = (): void => {
- if (stopped) return;
- const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
- reconnectAttempt++;
- wsUrl = null;
- reconnectTimer = setTimeout(tryConnect, delay);
+ attempt().catch(() => {
+ if (isCurrentAttempt(generation)) {
+ connecting = false;
+ }
+ });
};
const start = async (): Promise<void> => {
@@ -296,22 +421,51 @@ export const createCdpBridge = (): CdpBridge => {
const stop = async (): Promise<void> => {
stopped = true;
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
- mux.takeAll();
- signalConnected();
- if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
+ attemptGeneration += 1;
+ activeAttempt = 0;
+ activeSocket = null;
+ connecting = false;
+ rejectAllPendingCallbacks();
+ if (ws) {
+ try {
+ ws.close(1000, "Shutdown");
+ } catch {}
+ ws = null;
+ }
wsUrl = null;
};
+ const waitForConnection = async (): Promise<boolean> => {
+ const deadline = Date.now() + connectTimeoutMs;
+ while (connecting && Date.now() < deadline) {
+ if (ws && ws.readyState === WebSocket.OPEN) return true;
+ await new Promise((r) => setTimeout(r, 5));
+ }
+ return ws !== null && ws.readyState === WebSocket.OPEN;
+ };
+
const handleRequest = async (
req: WireRequest,
clientId: string,
send: SendToClient,
): Promise<void> => {
- // Every queued request awaits the same connect signal rather than each spinning its own poll loop.
- if (!ws || ws.readyState !== WebSocket.OPEN) await waitForConnection(CONNECT_WAIT_MS);
+ let activeSocket = ws;
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
+ // Connection attempts happen only in response to explicit browser work.
+ tryConnect();
+ const ready = await waitForConnection();
+ if (!ready) {
+ send(clientId, {
+ type: "response",
+ id: req.id,
+ error: { code: -32000, message: "Chrome not connected" },
+ });
+ return;
+ }
+ }
- if (!ws || ws.readyState !== WebSocket.OPEN) {
+ activeSocket = ws;
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
send(clientId, {
type: "response",
id: req.id,
@@ -323,11 +477,14 @@ export const createCdpBridge = (): CdpBridge => {
const isDetach = req.method === "Target.detachFromTarget";
if (isDetach && req.sessionId) router.release(req.sessionId);
+ const isAttach = req.method === "Target.attachToTarget";
+
const daemonId = mux.allocate(
- { clientId, localId: req.id, send, isAttach: req.method === "Target.attachToTarget" },
+ { clientId, localId: req.id, send, isAttach },
(id) =>
setTimeout(() => {
mux.take(id);
+ generations.delete(id);
send(clientId, {
type: "response",
id: req.id,
@@ -335,6 +492,7 @@ export const createCdpBridge = (): CdpBridge => {
});
}, CDP_COMMAND_TIMEOUT_MS),
);
+ generations.set(daemonId, { generation: attemptGeneration, clientId });
const payload: Record<string, unknown> = {
id: daemonId,
@@ -344,9 +502,10 @@ export const createCdpBridge = (): CdpBridge => {
if (req.sessionId) payload["sessionId"] = req.sessionId;
try {
- ws.send(JSON.stringify(payload));
+ activeSocket.send(JSON.stringify(payload));
} catch (e) {
mux.take(daemonId);
+ generations.delete(daemonId);
send(clientId, {
type: "response",
id: req.id,
@@ -361,7 +520,11 @@ export const createCdpBridge = (): CdpBridge => {
handleRequest,
isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
getSessionOwner: (sid) => router.getOwner(sid),
- removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
+ removeClient: (cid) => {
+ rejectCallbacksWhere((info) => info.clientId === cid);
+ mux.clearClient(cid);
+ router.removeClient(cid);
+ },
onEvent: (h) => { eventHandler = h; },
onClose: (h) => { closeHandler = h; },
};
diff --git a/test/daemon/bridge-connection-test.ts b/test/daemon/bridge-connection-test.ts
new file mode 100644
index 0000000..d004b97
--- /dev/null
+++ b/test/daemon/bridge-connection-test.ts
@@ -0,0 +1,616 @@
+/**
+ * Regression tests for `createCdpBridge` connection lifecycle races.
+ *
+ * Covers remaining lifecycle regressions after PR #18:
+ * 1. discovery must be bounded by `connectTimeoutMs`
+ * 2. handleRequest should stop waiting as soon as the active attempt settles
+ * 3. stop() and a dropped connection must resolve pending callbacks
+ * 4. constructor and stale-generation cleanup still must reject old callbacks
+ */
+
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import type { WireRequest, WireResponse } from "../../src/daemon/protocol";
+import type { CdpError } from "../../src/cdp/errors";
+import type { Result } from "../../src/util/result";
+import { createCdpBridge } from "../../src/daemon/bridge";
+import type WebSocket from "ws";
+
+type ParsedSend = { id?: number; method?: string };
+type Discover = () => Promise<Result<string, CdpError>>;
+type SendResult = WireResponse;
+
+const check = (cond: boolean, label: string): void => {
+ assert.ok(cond, label);
+};
+
+const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
+
+const waitFor = async (
+ predicate: () => boolean,
+ _label: string,
+ timeoutMs = 120,
+ intervalMs = 2,
+): Promise<boolean> => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (predicate()) return true;
+ await sleep(intervalMs);
+ }
+ return false;
+};
+
+class FakeWebSocket extends EventEmitter {
+ static CONNECTING = 0;
+ static OPEN = 1;
+ static CLOSING = 2;
+ static CLOSED = 3;
+
+ public readyState = FakeWebSocket.CONNECTING;
+ public readonly sent: string[] = [];
+ private readonly closeDelayMs: number;
+
+ constructor(readonly label: string, options: { closeDelayMs?: number } = {}) {
+ super();
+ this.closeDelayMs = options.closeDelayMs ?? 0;
+ }
+
+ send(payload: string): void {
+ this.sent.push(payload);
+ }
+
+ close(code?: number, reason?: string): void {
+ if (this.readyState === FakeWebSocket.CLOSED) return;
+
+ const closeNow = (): void => {
+ if (this.readyState === FakeWebSocket.CLOSED) return;
+ this.readyState = FakeWebSocket.CLOSED;
+ this.emit("close", code, reason);
+ };
+
+ if (this.closeDelayMs > 0) {
+ this.readyState = FakeWebSocket.CLOSING;
+ setTimeout(closeNow, this.closeDelayMs);
+ return;
+ }
+
+ closeNow();
+ }
+
+ open(): void {
+ if (this.readyState === FakeWebSocket.OPEN) return;
+ this.readyState = FakeWebSocket.OPEN;
+ this.emit("open");
+ }
+
+ fail(error = new Error("socket error")): void {
+ this.emit("error", error);
+ }
+
+ message(data: string): void {
+ this.emit("message", data);
+ }
+}
+
+const makeDiscover = (urls: string[]): Discover => {
+ return async () => {
+ const next = urls.shift();
+ if (!next) {
+ return {
+ success: false,
+ error: {
+ kind: "discovery_failed",
+ message: "no more URLs",
+ },
+ };
+ }
+ return { success: true, data: next };
+ };
+};
+
+const requestIdByMethod = (socket: FakeWebSocket, method: string): number | null => {
+ for (const payload of [...socket.sent].reverse()) {
+ try {
+ const parsed = JSON.parse(payload) as ParsedSend;
+ if (parsed.method === method && typeof parsed.id === "number") {
+ return parsed.id;
+ }
+ } catch {
+ continue;
+ }
+ }
+ return null;
+};
+
+const withSocket = (
+ sockets: FakeWebSocket[],
+ index: number,
+ label: string,
+): FakeWebSocket | null => {
+ const socket = sockets[index] ?? null;
+ if (!socket) {
+ check(false, label);
+ return null;
+ }
+ return socket;
+};
+
+// --- Test 1: constructor failure settles the attempt ---------------------
+
+const testConstructorFailureSettlesAttempt = async (): Promise<void> => {
+ let settled = false;
+ const bridge = createCdpBridge({
+ discoverWsUrl: async (): Promise<Result<string, CdpError>> => ({
+ success: true,
+ data: "ws://never-used.invalid:9",
+ }),
+ createWebSocket: () => {
+ throw new Error("simulated constructor throw");
+ },
+ connectTimeoutMs: 5,
+ onAttemptSettled: () => {
+ settled = true;
+ },
+ });
+
+ await bridge.start();
+ check(await waitFor(() => settled, "constructor failure path calls settlement callback", 50), "constructor failure path calls settlement callback");
+};
+
+// --- Test 2: bounded discovery is required for unresolved discoverWsUrl ----
+
+const testUnresolvedDiscoveryIsBounded = async (): Promise<void> => {
+ let settled = 0;
+ let discoverCalls = 0;
+ const sockets: FakeWebSocket[] = [];
+ const bridge = createCdpBridge({
+ discoverWsUrl: async () => {
+ discoverCalls += 1;
+ if (discoverCalls === 1) {
+ return new Promise(() => undefined);
+ }
+ return { success: true, data: "ws://retry-attempt.invalid:9222" };
+ },
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ connectTimeoutMs: 25,
+ onAttemptSettled: () => {
+ settled += 1;
+ },
+ });
+
+ await bridge.start();
+ check(await waitFor(() => settled === 1, "unresolved discovery settles by timeout", 100), "unresolved discovery is bounded by timeout");
+ check(sockets.length === 0, "timed-out discovery attempt does not create a socket");
+ check(discoverCalls === 1, "only one discover call happened before timeout");
+
+ await bridge.start();
+ check(
+ await waitFor(() => discoverCalls >= 2 && sockets.length > 0, "retry discovery creates a fresh socket after timeout", 150),
+ "retry discovery runs after bounded first attempt",
+ );
+ const retrySocket = withSocket(sockets, 0, "retry socket exists after bounded discovery timeout");
+ if (!retrySocket) {
+ await bridge.stop();
+ return;
+ }
+
+ retrySocket.open();
+ check(await waitFor(() => bridge.isAlive(), "bridge reconnects after bounded timeout"), "bridge recovers after bounded discovery timeout");
+ await bridge.stop();
+};
+
+// --- Test 3: failed attempt should short-circuit handleRequest wait -------
+
+const testHandleRequestStopsPollingAfterFailedAttempt = async (): Promise<void> => {
+ const responses: SendResult[] = [];
+ const sockets: FakeWebSocket[] = [];
+ const bridge = createCdpBridge({
+ discoverWsUrl: async (): Promise<Result<string, CdpError>> => ({
+ success: true,
+ data: "ws://fast-fail.invalid:9222",
+ }),
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ setTimeout(() => socket.fail(new Error("forced connect failure")), 0);
+ return socket as unknown as WebSocket;
+ },
+ connectTimeoutMs: 30,
+ });
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length === 1, "connect attempt creates socket", 50), "handleRequest fast-fail path creates a socket");
+
+ const start = Date.now();
+ await bridge.handleRequest(
+ { type: "request", id: 20, method: "Runtime.enable", params: {} },
+ "client-fast",
+ (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ },
+ );
+ const elapsed = Date.now() - start;
+
+ check(elapsed < 200, "handleRequest returns quickly after failed attempt settlement");
+ check(
+ responses.some((msg) => msg.id === 20 && msg.error?.message === "Chrome not connected"),
+ "handleRequest fails fast with Chrome not connected",
+ );
+ await bridge.stop();
+};
+
+// --- Test 4: stop() must settle pending callbacks -----------------------
+
+const testStopRejectsPendingRequestCallbacks = async (): Promise<void> => {
+ const responses: SendResult[] = [];
+ const sockets: FakeWebSocket[] = [];
+ const bridge = createCdpBridge({
+ discoverWsUrl: async (): Promise<Result<string, CdpError>> => ({
+ success: true,
+ data: "ws://stop-callback.invalid:9222",
+ }),
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ });
+
+ await bridge.start();
+ const initial = await waitFor(() => sockets.length === 1, "socket created for stop test", 50);
+ check(initial, "socket created for stop test");
+ if (!initial) {
+ await bridge.stop();
+ return;
+ }
+
+ const socket = sockets[0];
+ if (!socket) {
+ await bridge.stop();
+ return;
+ }
+
+ socket.open();
+ const req: WireRequest = { type: "request", id: 40, method: "Runtime.enable", params: {} };
+ await bridge.handleRequest(req, "client-stop", (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ });
+
+ const sentDaemonId = requestIdByMethod(socket, "Runtime.enable");
+ check(sentDaemonId !== null, "stop test request is sent");
+
+ await bridge.stop();
+ check(
+ await waitFor(
+ () => responses.some((msg) => msg.id === 40 && msg.error?.message === "Chrome disconnected"),
+ "stop settles pending callback with Chrome disconnected",
+ 80,
+ ),
+ "stop sends Chrome-disconnected response for pending callback",
+ );
+};
+
+// --- Test 5: an established connection dropping rejects its in-flight requests ---
+
+const testDropRejectsInFlightRequests = async (): Promise<void> => {
+ const sockets: FakeWebSocket[] = [];
+ const responses: SendResult[] = [];
+
+ const bridge = createCdpBridge({
+ connectTimeoutMs: 60,
+ discoverWsUrl: async (): Promise<Result<string, CdpError>> => ({
+ success: true,
+ data: "ws://drop-inflight.invalid:9222",
+ }),
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ });
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length === 1, "drop test socket created", 50), "drop test socket created");
+ const socket = withSocket(sockets, 0, "drop test socket exists");
+ if (!socket) {
+ await bridge.stop();
+ return;
+ }
+
+ socket.open();
+ const req: WireRequest = { type: "request", id: 41, method: "Runtime.enable", params: {} };
+ await bridge.handleRequest(req, "client-drop", (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ });
+ check(requestIdByMethod(socket, "Runtime.enable") !== null, "drop test request is in flight");
+ check(responses.length === 0, "drop test request has no response yet");
+
+ // The attempt already settled when the socket opened, so the close path must reject
+ // its callbacks itself rather than leaving them to the 10s command timeout.
+ socket.close();
+ check(
+ await waitFor(
+ () => responses.some((msg) => msg.id === 41 && msg.error?.message === "Chrome disconnected"),
+ "in-flight request rejected on drop",
+ 120,
+ ),
+ "a dropped connection rejects its in-flight requests immediately",
+ );
+
+ await bridge.stop();
+};
+
+// --- Test 6: stale failure attempt cannot resurrect bridge ----------------
+
+const testStaleFailureAttemptCannotResurrect = async (): Promise<void> => {
+ const runStaleClosedSocketCase = async (): Promise<void> => {
+ const sockets: FakeWebSocket[] = [];
+ const responses: SendResult[] = [];
+ const urls = ["ws://timedout-attempt.invalid:9222", "ws://retry-attempt.invalid:9222"];
+ let settled = 0;
+
+ const bridge = createCdpBridge({
+ discoverWsUrl: makeDiscover(urls),
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ connectTimeoutMs: 15,
+ onAttemptSettled: () => {
+ settled += 1;
+ },
+ });
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length >= 1, "stale closed socket attempt creates first socket", 50), "stale closed socket attempt creates first socket");
+ const first = withSocket(sockets, 0, "first stale socket exists");
+ if (!first) {
+ await bridge.stop();
+ return;
+ }
+
+ first.open();
+ first.close();
+ check(await waitFor(() => settled >= 1, "stale closed socket attempt settles", 80), "stale closed socket attempt settles");
+ check(!bridge.isAlive(), "stale closed socket does not resurrect bridge");
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length >= 2, "retry socket is created after stale close", 80), "retry socket is created after stale close");
+ const second = withSocket(sockets, 1, "second socket exists after retry");
+ if (!second) {
+ await bridge.stop();
+ return;
+ }
+
+ second.open();
+ check(await waitFor(() => bridge.isAlive(), "bridge reconnects after stale close", 80), "bridge reconnects after stale close");
+
+ await bridge.handleRequest(
+ { type: "request", id: 10, method: "Runtime.enable", params: {} },
+ "client-a",
+ (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ },
+ );
+
+ const secondChromeId = requestIdByMethod(second, "Runtime.enable");
+ check(secondChromeId !== null, "second attempt sent the request");
+ if (secondChromeId === null) {
+ await bridge.stop();
+ return;
+ }
+
+ first.message(JSON.stringify({ id: secondChromeId, result: { late: true } }));
+ check(!responses.some((msg) => msg.id === 10), "stale closed socket cannot resolve live callback");
+
+ second.message(JSON.stringify({ id: secondChromeId, result: { ok: true } }));
+ check(
+ await waitFor(() => responses.some((msg) => msg.id === 10), "second attempt callback is resolved", 60),
+ "current callback resolves from active socket",
+ );
+
+ await bridge.stop();
+ };
+
+ const runErrorCase = async (): Promise<void> => {
+ const sockets: FakeWebSocket[] = [];
+ const responses: SendResult[] = [];
+ const urls = ["ws://errored-attempt.invalid:9222", "ws://retry-after-error.invalid:9222"];
+
+ const bridge = createCdpBridge({
+ discoverWsUrl: makeDiscover(urls),
+ createWebSocket: (url: string): WebSocket => {
+ const socket = new FakeWebSocket(url);
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ connectTimeoutMs: 20,
+ });
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length === 1, "initial attempt socket is created", 50), "error test creates first socket");
+ const first = withSocket(sockets, 0, "initial socket exists after error");
+ if (!first) {
+ await bridge.stop();
+ return;
+ }
+
+ first.open();
+
+ await bridge.handleRequest(
+ { type: "request", id: 11, method: "Runtime.enable", params: {} },
+ "client-b",
+ (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ },
+ );
+
+ first.fail(new Error("forced failure"));
+ first.close();
+
+ await bridge.start();
+ check(
+ await waitFor(() => sockets.length >= 2, "retry socket is created after error", 100),
+ "retry socket is created after error",
+ );
+ const second = withSocket(sockets, 1, "retry socket exists after error");
+ if (!second) {
+ await bridge.stop();
+ return;
+ }
+
+ second.open();
+ check(await waitFor(() => bridge.isAlive(), "bridge reconnects after error", 80), "bridge reconnects after error");
+
+ await bridge.handleRequest(
+ { type: "request", id: 12, method: "Runtime.disable", params: {} },
+ "client-b",
+ (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ },
+ );
+
+ const secondChromeId = requestIdByMethod(second, "Runtime.disable");
+ check(secondChromeId !== null, "second attempt sent the request");
+ if (secondChromeId === null) {
+ await bridge.stop();
+ return;
+ }
+
+ first.message(JSON.stringify({ id: secondChromeId, result: { late: true } }));
+ check(!responses.some((msg) => msg.id === 12), "stale message from errored socket does not resolve current callback");
+
+ second.message(JSON.stringify({ id: secondChromeId, result: { ok: true } }));
+ check(
+ await waitFor(() => responses.some((msg) => msg.id === 12), "current callback resolves after retry response", 80),
+ "current callback resolves after retry response",
+ );
+
+ await bridge.stop();
+ };
+
+ await runStaleClosedSocketCase();
+ await runErrorCase();
+};
+
+// --- Test 7: old-generation close should not affect newer callback ----------
+
+const testOldGenerationCloseDoesNotAffectNewCallbacks = async (): Promise<void> => {
+ const sockets: FakeWebSocket[] = [];
+ const responses: SendResult[] = [];
+ const urls = ["ws://old-gen.invalid:9222", "ws://new-gen.invalid:9222"];
+
+ const bridge = createCdpBridge({
+ discoverWsUrl: makeDiscover(urls),
+ createWebSocket: (url: string): WebSocket => {
+ const delayCloseMs = sockets.length === 0 ? 12 : 0;
+ const socket = new FakeWebSocket(url, { closeDelayMs: delayCloseMs });
+ sockets.push(socket);
+ return socket as unknown as WebSocket;
+ },
+ connectTimeoutMs: 50,
+ });
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length === 1, "old-generation socket is created", 50), "old generation socket exists");
+ const first = withSocket(sockets, 0, "old generation socket exists");
+ if (!first) {
+ await bridge.stop();
+ return;
+ }
+
+ first.open();
+
+ const oldRequest: WireRequest = { type: "request", id: 31, method: "Target.getTargets", params: {} };
+ await bridge.handleRequest(oldRequest, "client-c", (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ });
+
+ const oldChromeId = requestIdByMethod(first, "Target.getTargets");
+ check(oldChromeId !== null, "old-generation request is in-flight");
+ if (!oldChromeId) {
+ await bridge.stop();
+ return;
+ }
+
+ first.close();
+
+ await bridge.start();
+ check(await waitFor(() => sockets.length >= 2, "new generation socket is created", 80), "new generation socket exists");
+ const second = withSocket(sockets, 1, "new generation socket exists");
+ if (!second) {
+ await bridge.stop();
+ return;
+ }
+
+ second.open();
+ check(await waitFor(() => bridge.isAlive(), "bridge stays alive on new generation", 80), "bridge stays alive on new generation");
+
+ const newRequest: WireRequest = { type: "request", id: 32, method: "Runtime.getProperties", params: {} };
+ await bridge.handleRequest(newRequest, "client-c", (_clientId, msg) => {
+ if (msg.type === "response") {
+ responses.push(msg);
+ }
+ });
+
+ const newChromeId = requestIdByMethod(second, "Runtime.getProperties");
+ check(newChromeId !== null, "new-generation request is sent");
+ if (!newChromeId) {
+ await bridge.stop();
+ return;
+ }
+
+ check(!responses.some((msg) => msg.id === 31), "old generation callback is pending before close");
+
+ second.message(JSON.stringify({ id: newChromeId, result: { ok: true } }));
+ check(
+ await waitFor(() => responses.some((msg) => msg.id === 32), "new generation callback resolves", 80),
+ "new-generation callback resolves",
+ );
+
+ check(
+ await waitFor(
+ () => responses.some((msg) => msg.id === 31 && msg.error?.message === "Chrome disconnected"),
+ "old-generation close rejects old callbacks",
+ 80,
+ ),
+ "old-generation callback receives Chrome-disconnected error",
+ );
+
+ check(
+ !responses.some((msg) => msg.id === 32 && msg.error !== undefined),
+ "new-generation callback is unaffected by old-generation close",
+ );
+
+ await bridge.stop();
+};
+
+describe("cdp bridge connection lifecycle", () => {
+ test("a WebSocket constructor failure settles the attempt", testConstructorFailureSettlesAttempt);
+ test("an unresolved discovery is bounded by the connect timeout", testUnresolvedDiscoveryIsBounded);
+ test("handleRequest stops waiting once the attempt fails", testHandleRequestStopsPollingAfterFailedAttempt);
+ test("stop() rejects pending request callbacks", testStopRejectsPendingRequestCallbacks);
+ test("a dropped connection rejects its in-flight requests", testDropRejectsInFlightRequests);
+ test("a stale failed attempt cannot resurrect the bridge", testStaleFailureAttemptCannotResurrect);
+ test("an old-generation close leaves new-generation callbacks alone", testOldGenerationCloseDoesNotAffectNewCallbacks);
+});