diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 6267868276..291d919096 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -9,6 +9,7 @@ import { bindDomains, type Domains, type Transport } from './generated.ts'; type Pending = { + ws: WebSocket; resolve: (v: unknown) => void; reject: (e: unknown) => void; }; @@ -46,6 +47,9 @@ export class Session implements Transport { private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; + private activeTargetId: string | undefined; + private reattachPromise?: Promise; + private enabledDomains = new Map>(); private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = []; private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = []; @@ -79,6 +83,11 @@ export class Session implements Transport { * and we connect directly to the supplied endpoint. */ async connect(opts: ConnectOptions = {}): Promise { + // No-argument connect is an ensure-connected operation. Reopening the + // same configured endpoint would discard the active target session and + // make the next page command run against the browser-level socket. + if (!opts.wsUrl && !opts.profileDir && this.isConnected()) return; + const timeoutMs = opts.timeoutMs ?? 5_000; if (opts.wsUrl || opts.profileDir) { const wsUrl = await resolveWsUrl(opts, timeoutMs); @@ -124,15 +133,33 @@ export class Session implements Transport { else res(); }; const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs); - ws.addEventListener('open', () => finish()); + ws.addEventListener('open', () => { + if (done) { + try { ws.close(); } catch { /* ignore */ } + return; + } + const previous = this.ws; + this.ws = ws; + this.activeSessionId = undefined; + this.activeTargetId = undefined; + this.enabledDomains.clear(); + finish(); + if (previous && previous !== ws) { + try { previous.close(); } catch { /* ignore */ } + } + }); ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`))); - ws.addEventListener('message', (e) => this.onMessage(String(e.data))); + ws.addEventListener('message', (e) => this.onMessage(String(e.data), ws)); ws.addEventListener('close', () => { - for (const [, p] of this.pending) p.reject(new Error('CDP socket closed')); - this.pending.clear(); + this.rejectPending(ws, new Error('CDP socket closed')); + if (this.ws === ws) { + this.ws = undefined; + this.activeSessionId = undefined; + this.activeTargetId = undefined; + this.enabledDomains.clear(); + } finish(new Error('WS closed before open (likely 403 or port closed)')); }); - this.ws = ws; }); } @@ -151,12 +178,14 @@ export class Session implements Transport { async use(targetId: string): Promise { const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string }; this.activeSessionId = r.sessionId; + this.activeTargetId = targetId; return r.sessionId; } /** Set the active sessionId directly (e.g. one you already attached). */ setActiveSession(sessionId: string | undefined): void { this.activeSessionId = sessionId; + this.activeTargetId = undefined; } getActiveSession(): string | undefined { @@ -206,18 +235,41 @@ export class Session implements Transport { } // Transport implementation. Called by the generated domain bindings. - _call(method: string, params: unknown = {}): Promise { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + async _call(method: string, params: unknown = {}): Promise { + const browserLevel = isBrowserLevel(method); + const sentSessionId = browserLevel ? undefined : this.activeSessionId; + const sentTargetId = browserLevel ? undefined : this.activeTargetId; + try { + return await this.send(method, params, sentSessionId); + } catch (error) { + if (!sentSessionId || !isMissingSessionError(error)) throw error; + + // Chrome explicitly rejected the command before executing it, so this is + // safe to retry once. Socket drops are deliberately not retried: Chrome + // may have applied a click or submission before the response was lost. + if (this.activeSessionId === sentSessionId) { + await this.reattachPage(sentSessionId, sentTargetId); + } + else if (this.reattachPromise) await this.reattachPromise; + if (!this.activeSessionId || this.activeSessionId === sentSessionId) throw error; + return this.send(method, params, this.activeSessionId); + } + } + + private send(method: string, params: unknown, sessionId?: string): Promise { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error('Not connected. Call session.connect(...) first.')); } + const id = this.nextId++; const msg: Record = { id, method, params: params ?? {} }; - if (this.activeSessionId && !isBrowserLevel(method)) { - msg.sessionId = this.activeSessionId; - } + if (sessionId) msg.sessionId = sessionId; return new Promise((resolve, reject) => { this.pending.set(id, { + ws, resolve: (v) => { + this.recordDomainState(method, params, sessionId); for (const fn of this.callResultListeners) { try { fn(method, params, v); } catch { /* ignore */ } } @@ -225,16 +277,91 @@ export class Session implements Transport { }, reject, }); - this.ws!.send(JSON.stringify(msg)); + try { + ws.send(JSON.stringify(msg)); + } catch (error) { + this.pending.delete(id); + reject(error); + } }); } - private onMessage(raw: string): void { + private async reattachPage(staleSessionId: string, staleTargetId?: string): Promise { + if (this.reattachPromise) return this.reattachPromise; + + const attempt = this.attachPage(staleSessionId, staleTargetId); + this.reattachPromise = attempt; + try { + await attempt; + } finally { + if (this.reattachPromise === attempt) this.reattachPromise = undefined; + } + } + + private async attachPage(staleSessionId: string, staleTargetId?: string): Promise { + if (!staleTargetId) { + if (this.activeSessionId === staleSessionId) { + this.activeSessionId = undefined; + this.activeTargetId = undefined; + this.enabledDomains.delete(staleSessionId); + } + throw new Error( + 'CDP target session was lost and its target is unknown; command was not retried on another page.', + ); + } + const domainsToRestore = [...(this.enabledDomains.get(staleSessionId)?.entries() ?? [])]; + const { targetInfos } = await this.domains.Target.getTargets({}); + const pages = targetInfos as PageTarget[]; + const exactTarget = pages.find( + target => target.type === 'page' && target.targetId === staleTargetId, + ); + if (!exactTarget) { + if (this.activeSessionId === staleSessionId) { + this.activeSessionId = undefined; + this.activeTargetId = undefined; + this.enabledDomains.delete(staleSessionId); + } + throw new Error( + `CDP target ${staleTargetId} was closed; command was not retried on another page.`, + ); + } + const sessionId = await this.use(exactTarget.targetId); + await Promise.all( + domainsToRestore.map( + ([method, params]) => this.send(method, params, sessionId), + ), + ); + this.enabledDomains.delete(staleSessionId); + } + + private recordDomainState(method: string, params: unknown, sessionId?: string): void { + if (!sessionId) return; + const match = /^([^.]+)\.(enable|disable)$/.exec(method); + if (!match) return; + const [, domain, command] = match; + if (!domain || !command) return; + const enabled = this.enabledDomains.get(sessionId) ?? new Map(); + if (command === 'enable') enabled.set(`${domain}.enable`, params); + else enabled.delete(`${domain}.enable`); + if (enabled.size > 0) this.enabledDomains.set(sessionId, enabled); + else this.enabledDomains.delete(sessionId); + } + + private rejectPending(ws: WebSocket, error: Error): void { + for (const [id, pending] of this.pending) { + if (pending.ws !== ws) continue; + this.pending.delete(id); + pending.reject(error); + } + } + + private onMessage(raw: string, ws: WebSocket): void { + if (ws !== this.ws) return; let m: any; try { m = JSON.parse(raw); } catch { return; } if (typeof m.id === 'number') { const p = this.pending.get(m.id); - if (!p) return; + if (!p || p.ws !== ws) return; this.pending.delete(m.id); if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data)); else p.resolve(m.result); @@ -258,6 +385,12 @@ function isBrowserLevel(method: string): boolean { return method.startsWith('Browser.') || method.startsWith('Target.'); } +function isMissingSessionError(error: unknown): boolean { + return error instanceof CdpError + && error.code === -32001 + && error.message.includes('Session with given id not found'); +} + /** * Resolve a WebSocket URL for one of the explicit connect forms: * { wsUrl } — passthrough. @@ -423,4 +556,3 @@ async function tryReadDevToolsActivePort( return undefined; } } - diff --git a/packages/bcode-browser/test/cdp-recovery.test.ts b/packages/bcode-browser/test/cdp-recovery.test.ts new file mode 100644 index 0000000000..361175f96c --- /dev/null +++ b/packages/bcode-browser/test/cdp-recovery.test.ts @@ -0,0 +1,462 @@ +import { expect, test } from "bun:test" +import { Session } from "../src/cdp/session" + +const wsUrl = (server: { port?: number }) => { + if (server.port === undefined) throw new Error("test server has no port") + return `ws://127.0.0.1:${server.port}/` +} + +test("a missing page session is reattached once and the rejected command is retried", async () => { + let attachCount = 0 + let getTargetsCount = 0 + let staleCommandCount = 0 + const commandSessions: string[] = [] + const attachedTargets: string[] = [] + const enabledDomains: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method === "Target.attachToTarget") { + attachCount++ + attachedTargets.push(message.params.targetId) + socket.send(JSON.stringify({ id: message.id, result: { sessionId: `session-${attachCount}` } })) + return + } + if (message.method === "Target.getTargets") { + getTargetsCount++ + setTimeout(() => { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [ + { targetId: "other-page", title: "Other", type: "page", url: "https://other.example" }, + { targetId: "page-1", title: "Page", type: "page", url: "https://example.com" }, + ], + }, + })) + }, 10) + return + } + if (message.method === "Debugger.enable") { + enabledDomains.push(message.method) + socket.send(JSON.stringify({ id: message.id, result: {} })) + return + } + if (message.method === "Runtime.evaluate") { + commandSessions.push(message.sessionId) + if (message.sessionId === "session-1") { + staleCommandCount++ + const rejectMissingSession = () => { + socket.send(JSON.stringify({ + id: message.id, + error: { code: -32001, message: "Session with given id not found." }, + })) + } + if (staleCommandCount === 3) setTimeout(rejectMissingSession, 30) + else rejectMissingSession() + } else { + socket.send(JSON.stringify({ + id: message.id, + result: { result: { type: "number", value: message.params.expression } }, + })) + } + } + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.use("page-1") + await session.domains.Debugger.enable({}) + const [first, second, third] = await Promise.all([ + session.domains.Runtime.evaluate({ expression: "1" }), + session.domains.Runtime.evaluate({ expression: "2" }), + session.domains.Runtime.evaluate({ expression: "3" }), + ]) + + expect(first.result.value).toBe("1") + expect(second.result.value).toBe("2") + expect(third.result.value).toBe("3") + expect(attachCount).toBe(2) + expect(attachedTargets).toEqual(["page-1", "page-1"]) + expect(getTargetsCount).toBe(1) + expect(enabledDomains).toEqual(["Debugger.enable", "Debugger.enable"]) + expect(commandSessions).toEqual([ + "session-1", + "session-1", + "session-1", + "session-2", + "session-2", + "session-2", + ]) + } finally { + session.close() + server.stop(true) + } +}) + +test("calls started during reattachment join the same recovery", async () => { + let attachCount = 0 + let markReattachStarted: (() => void) | undefined + const reattachStarted = new Promise((resolve) => { + markReattachStarted = resolve + }) + const commandSessions: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method === "Target.attachToTarget") { + attachCount++ + socket.send(JSON.stringify({ id: message.id, result: { sessionId: `session-${attachCount}` } })) + return + } + if (message.method === "Target.getTargets") { + markReattachStarted?.() + setTimeout(() => { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [{ targetId: "page-1", title: "Page", type: "page", url: "https://example.com" }], + }, + })) + }, 20) + return + } + if (message.method !== "Runtime.evaluate") return + commandSessions.push(message.sessionId) + if (message.sessionId === "session-1") { + socket.send(JSON.stringify({ + id: message.id, + error: { code: -32001, message: "Session with given id not found." }, + })) + return + } + socket.send(JSON.stringify({ + id: message.id, + result: { result: { type: "string", value: message.params.expression } }, + })) + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.use("page-1") + const first = session.domains.Runtime.evaluate({ expression: "first" }) + await reattachStarted + const second = session.domains.Runtime.evaluate({ expression: "second" }) + + expect((await first).result.value).toBe("first") + expect((await second).result.value).toBe("second") + expect(attachCount).toBe(2) + expect(commandSessions).toEqual(["session-1", "session-1", "session-2", "session-2"]) + } finally { + session.close() + server.stop(true) + } +}) + +test("reattach reuses an existing about:blank target", async () => { + let attachCount = 0 + let createCount = 0 + const attachedTargets: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method === "Target.attachToTarget") { + attachCount++ + attachedTargets.push(message.params.targetId) + socket.send(JSON.stringify({ id: message.id, result: { sessionId: `session-${attachCount}` } })) + return + } + if (message.method === "Target.getTargets") { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [{ targetId: "blank-page", title: "", type: "page", url: "about:blank" }], + }, + })) + return + } + if (message.method === "Target.createTarget") { + createCount++ + socket.send(JSON.stringify({ id: message.id, result: { targetId: "unexpected-page" } })) + return + } + if (message.method !== "Runtime.evaluate") return + if (message.sessionId === "session-1") { + socket.send(JSON.stringify({ + id: message.id, + error: { code: -32001, message: "Session with given id not found." }, + })) + return + } + socket.send(JSON.stringify({ + id: message.id, + result: { result: { type: "boolean", value: true } }, + })) + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.use("blank-page") + const result = await session.domains.Runtime.evaluate({ expression: "true" }) + + expect(result.result.value).toBe(true) + expect(createCount).toBe(0) + expect(attachedTargets).toEqual(["blank-page", "blank-page"]) + } finally { + session.close() + server.stop(true) + } +}) + +test("a missing original target is reported without replaying on another page", async () => { + let attachCount = 0 + let createCount = 0 + let commandCount = 0 + const attachedTargets: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method === "Target.attachToTarget") { + attachCount++ + attachedTargets.push(message.params.targetId) + socket.send(JSON.stringify({ id: message.id, result: { sessionId: `session-${attachCount}` } })) + return + } + if (message.method === "Target.getTargets") { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [ + { targetId: "other-page", title: "Other", type: "page", url: "https://other.example" }, + { targetId: "settings", title: "Settings", type: "page", url: "chrome://settings" }, + ], + }, + })) + return + } + if (message.method === "Target.createTarget") { + createCount++ + socket.send(JSON.stringify({ id: message.id, result: { targetId: "unexpected-page" } })) + return + } + if (["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"].includes(message.method)) { + socket.send(JSON.stringify({ id: message.id, result: {} })) + return + } + if (message.method === "Runtime.evaluate") { + commandCount++ + if (message.sessionId === "session-1") { + socket.send(JSON.stringify({ + id: message.id, + error: { code: -32001, message: "Session with given id not found." }, + })) + } else { + socket.send(JSON.stringify({ id: message.id, result: { result: { type: "boolean", value: true } } })) + } + } + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.use("old-page") + await expect(session.domains.Runtime.evaluate({ expression: "submit()" })) + .rejects.toThrow("CDP target old-page was closed") + + expect(commandCount).toBe(1) + expect(createCount).toBe(0) + expect(attachCount).toBe(1) + expect(attachedTargets).toEqual(["old-page"]) + } finally { + session.close() + server.stop(true) + } +}) + +test("no-argument connect preserves a healthy socket and active target", async () => { + let connectionCount = 0 + const commandSessions: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + if (!bunServer.upgrade(req)) return new Response("nope", { status: 400 }) + connectionCount++ + return undefined + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method === "Target.attachToTarget") { + socket.send(JSON.stringify({ id: message.id, result: { sessionId: "session-1" } })) + return + } + if (message.method !== "Runtime.evaluate") return + commandSessions.push(message.sessionId) + socket.send(JSON.stringify({ + id: message.id, + result: { result: { type: "boolean", value: true } }, + })) + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.use("page-1") + await session.connect() + const result = await session.domains.Runtime.evaluate({ expression: "true" }) + + expect(result.result.value).toBe(true) + expect(connectionCount).toBe(1) + expect(commandSessions).toEqual(["session-1"]) + } finally { + session.close() + server.stop(true) + } +}) + +test("a socket drop rejects an in-flight command without replaying it", async () => { + let commandCount = 0 + const server = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + if (message.method !== "Input.insertText") return + commandCount++ + socket.close(1011, "connection dropped") + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await expect(session.domains.Input.insertText({ text: "only once" })).rejects.toThrow("CDP socket closed") + expect(commandCount).toBe(1) + } finally { + session.close() + server.stop(true) + } +}) + +test("a failed replacement connection leaves the working socket active", async () => { + const live = Bun.serve({ + port: 0, + fetch(req, bunServer) { + return bunServer.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + socket.send(JSON.stringify({ id: message.id, result: { targetInfos: [] } })) + }, + close() {}, + }, + }) + const rejecting = Bun.serve({ + port: 0, + fetch() { + return new Response("forbidden", { status: 403 }) + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(live) }) + await expect(session.connect({ wsUrl: wsUrl(rejecting), timeoutMs: 1_000 })).rejects.toThrow() + expect(session.isConnected()).toBe(true) + expect((await session.domains.Target.getTargets({})).targetInfos).toEqual([]) + } finally { + session.close() + live.stop(true) + rejecting.stop(true) + } +}) + +test("closing a replaced socket cannot reject commands on the new socket", async () => { + let connectionCount = 0 + const server = Bun.serve<{ connection: number }>({ + port: 0, + fetch(req, bunServer) { + const connection = ++connectionCount + return bunServer.upgrade(req, { data: { connection } }) + ? undefined + : new Response("nope", { status: 400 }) + }, + websocket: { + message(socket, raw) { + const message = JSON.parse(String(raw)) + setTimeout(() => { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [{ + targetId: `page-${socket.data.connection}`, + type: "page", + title: "Page", + url: "https://example.com", + attached: false, + canAccessOpener: false, + }], + }, + })) + }, 20) + }, + close() {}, + }, + }) + const session = new Session() + + try { + await session.connect({ wsUrl: wsUrl(server) }) + await session.connect({ wsUrl: wsUrl(server) }) + const { targetInfos } = await session.domains.Target.getTargets({}) + + expect(targetInfos[0]?.targetId).toBe("page-2") + } finally { + session.close() + server.stop(true) + } +})