From d9bc46b508f8c6f7e0f0ff5d54d804340f3e6c80 Mon Sep 17 00:00:00 2001 From: Uttkarsh <38376852+uttkarsh-26@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:20:49 +0530 Subject: fix: reconnect to Chrome only on explicit browser demand (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Hermes Agent Co-authored-by: uttkarsh-26 Co-authored-by: amankumarsingh77 --- CHANGELOG.md | 6 + src/daemon/bridge.ts | 361 ++++++++++++++------ test/daemon/bridge-connection-test.ts | 616 ++++++++++++++++++++++++++++++++++ 3 files changed, 884 insertions(+), 99 deletions(-) create mode 100644 test/daemon/bridge-connection-test.ts 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>; + 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(); - const waitForConnection = (timeoutMs: number): Promise => - 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 | 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 => { - 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 => { + let settled = false; + let sock: WebSocket | null = null; + let timeout: ReturnType | null = null; + let settleAttempt: () => void = () => {}; - const settledPromise = new Promise((settle) => { - let settled = false; - const settleOnce = (): void => { + const settledPromise = new Promise((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 => { @@ -296,22 +421,51 @@ export const createCdpBridge = (): CdpBridge => { const stop = async (): Promise => { 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 => { + 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 => { - // 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 = { 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>; +type SendResult = WireResponse; + +const check = (cond: boolean, label: string): void => { + assert.ok(cond, label); +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitFor = async ( + predicate: () => boolean, + _label: string, + timeoutMs = 120, + intervalMs = 2, +): Promise => { + 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 => { + let settled = false; + const bridge = createCdpBridge({ + discoverWsUrl: async (): Promise> => ({ + 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 => { + 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 => { + const responses: SendResult[] = []; + const sockets: FakeWebSocket[] = []; + const bridge = createCdpBridge({ + discoverWsUrl: async (): Promise> => ({ + 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 => { + const responses: SendResult[] = []; + const sockets: FakeWebSocket[] = []; + const bridge = createCdpBridge({ + discoverWsUrl: async (): Promise> => ({ + 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 => { + const sockets: FakeWebSocket[] = []; + const responses: SendResult[] = []; + + const bridge = createCdpBridge({ + connectTimeoutMs: 60, + discoverWsUrl: async (): Promise> => ({ + 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 => { + const runStaleClosedSocketCase = async (): Promise => { + 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 => { + 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 => { + 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); +}); -- cgit v1.2.3