diff options
| author | Uttkarsh <38376852+uttkarsh-26@users.noreply.github.com> | 2026-08-06 00:20:49 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-06 00:20:49 +0530 |
| commit | d9bc46b508f8c6f7e0f0ff5d54d804340f3e6c80 (patch) | |
| tree | 0ea1315ccc0feaee1ddf184ed16f16173455b3b4 /test | |
| parent | 31c1b27a50bc2b9ba7138ae67a4cfaba6945582e (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>
Diffstat (limited to 'test')
| -rw-r--r-- | test/daemon/bridge-connection-test.ts | 616 |
1 files changed, 616 insertions, 0 deletions
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); +}); |
