From e077bbadd67ad906b6edf9e5696ddff09f2092a5 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:53:09 -0700 Subject: [PATCH 1/4] fix(browser): recover stale CDP sessions --- packages/bcode-browser/src/cdp/session.ts | 111 +++++++- .../bcode-browser/test/cdp-recovery.test.ts | 259 ++++++++++++++++++ 2 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 packages/bcode-browser/test/cdp-recovery.test.ts diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 6267868276..e65cc0c5f2 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,7 @@ export class Session implements Transport { private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; + private reattachPromise?: Promise; private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = []; private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = []; @@ -124,15 +126,29 @@ 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; + 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; + } finish(new Error('WS closed before open (likely 403 or port closed)')); }); - this.ws = ws; }); } @@ -206,17 +222,36 @@ 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; + 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) this.activeSessionId = undefined; + if (!this.activeSessionId) await this.reattachFirstPage(); + if (!this.activeSessionId) 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) => { for (const fn of this.callResultListeners) { try { fn(method, params, v); } catch { /* ignore */ } @@ -225,16 +260,50 @@ 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 reattachFirstPage(): Promise { + if (this.reattachPromise) return this.reattachPromise; + + const attempt = this.attachFirstPage(); + this.reattachPromise = attempt; + try { + await attempt; + } finally { + if (this.reattachPromise === attempt) this.reattachPromise = undefined; + } + } + + private async attachFirstPage(): Promise { + const { targetInfos } = await this.domains.Target.getTargets({}); + const pages = targetInfos as PageTarget[]; + const targetId = pages.find(isUsablePageTarget)?.targetId + ?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId; + await this.use(targetId); + } + + 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 +327,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. @@ -329,6 +404,15 @@ export async function listPageTargets(session: Session): Promise { ); } +function isUsablePageTarget(target: PageTarget): boolean { + return target.type === 'page' + && !target.url.startsWith('chrome://') + && !target.url.startsWith('chrome-untrusted://') + && !target.url.startsWith('devtools://') + && !target.url.startsWith('chrome-extension://') + && !target.url.startsWith('about:'); +} + /** * Scan OS-specific user-data directories for Chromium-based browsers that * currently have remote debugging enabled (a `DevToolsActivePort` file exists @@ -423,4 +507,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..64cf6a70b5 --- /dev/null +++ b/packages/bcode-browser/test/cdp-recovery.test.ts @@ -0,0 +1,259 @@ +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 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") { + getTargetsCount++ + setTimeout(() => { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [{ targetId: "page-1", title: "Page", type: "page", url: "https://example.com" }], + }, + })) + }, 10) + 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") + 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(getTargetsCount).toBe(1) + expect(commandSessions).toEqual([ + "session-1", + "session-1", + "session-1", + "session-2", + "session-2", + "session-2", + ]) + } finally { + session.close() + server.stop(true) + } +}) + +test("reattach creates a blank page when only internal targets remain", async () => { + let attachCount = 0 + let createdTarget: unknown + 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") { + socket.send(JSON.stringify({ + id: message.id, + result: { + targetInfos: [{ targetId: "settings", title: "Settings", type: "page", url: "chrome://settings" }], + }, + })) + return + } + if (message.method === "Target.createTarget") { + createdTarget = message.params + socket.send(JSON.stringify({ id: message.id, result: { targetId: "fresh-page" } })) + return + } + if (message.method === "Runtime.evaluate") { + 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") + const result = await session.domains.Runtime.evaluate({ expression: "true" }) + + expect(result.result.value).toBe(true) + expect(createdTarget).toEqual({ url: "about:blank" }) + expect(attachCount).toBe(2) + } 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) + } +}) From 0fd16b057c01abb390ddb4dc98236a4c05a5510b Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:58:46 -0700 Subject: [PATCH 2/4] fix(browser): restore recovered domains --- packages/bcode-browser/src/cdp/session.ts | 7 ++++++- packages/bcode-browser/test/cdp-recovery.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index e65cc0c5f2..987934aefb 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -286,7 +286,12 @@ export class Session implements Transport { const pages = targetInfos as PageTarget[]; const targetId = pages.find(isUsablePageTarget)?.targetId ?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId; - await this.use(targetId); + const sessionId = await this.use(targetId); + await Promise.allSettled( + ['Page', 'DOM', 'Runtime', 'Network'].map( + domain => this.send(`${domain}.enable`, {}, sessionId), + ), + ); } private rejectPending(ws: WebSocket, error: Error): void { diff --git a/packages/bcode-browser/test/cdp-recovery.test.ts b/packages/bcode-browser/test/cdp-recovery.test.ts index 64cf6a70b5..fb95acdc22 100644 --- a/packages/bcode-browser/test/cdp-recovery.test.ts +++ b/packages/bcode-browser/test/cdp-recovery.test.ts @@ -11,6 +11,7 @@ test("a missing page session is reattached once and the rejected command is retr let getTargetsCount = 0 let staleCommandCount = 0 const commandSessions: string[] = [] + const enabledDomains: string[] = [] const server = Bun.serve({ port: 0, fetch(req, bunServer) { @@ -36,6 +37,11 @@ test("a missing page session is reattached once and the rejected command is retr }, 10) return } + if (["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"].includes(message.method)) { + 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") { @@ -75,6 +81,7 @@ test("a missing page session is reattached once and the rejected command is retr expect(third.result.value).toBe("3") expect(attachCount).toBe(2) expect(getTargetsCount).toBe(1) + expect(enabledDomains).toEqual(["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"]) expect(commandSessions).toEqual([ "session-1", "session-1", @@ -119,6 +126,10 @@ test("reattach creates a blank page when only internal targets remain", async () socket.send(JSON.stringify({ id: message.id, result: { targetId: "fresh-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") { if (message.sessionId === "session-1") { socket.send(JSON.stringify({ From c8c193c3db92c972ce6f65a2622069a4ba90f80b Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 12:51:27 -0700 Subject: [PATCH 3/4] fix(browser): preserve reattach state safely --- packages/bcode-browser/src/cdp/session.ts | 39 +++-- .../bcode-browser/test/cdp-recovery.test.ts | 137 +++++++++++++++++- 2 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 987934aefb..7834d0df97 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -48,6 +48,7 @@ export class Session implements Transport { private pending = new Map(); private activeSessionId: 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> = []; @@ -134,6 +135,7 @@ export class Session implements Transport { const previous = this.ws; this.ws = ws; this.activeSessionId = undefined; + this.enabledDomains.clear(); finish(); if (previous && previous !== ws) { try { previous.close(); } catch { /* ignore */ } @@ -146,6 +148,7 @@ export class Session implements Transport { if (this.ws === ws) { this.ws = undefined; this.activeSessionId = undefined; + this.enabledDomains.clear(); } finish(new Error('WS closed before open (likely 403 or port closed)')); }); @@ -233,9 +236,9 @@ export class Session implements Transport { // 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) this.activeSessionId = undefined; - if (!this.activeSessionId) await this.reattachFirstPage(); - if (!this.activeSessionId) throw error; + if (this.activeSessionId === sentSessionId) await this.reattachFirstPage(sentSessionId); + else if (this.reattachPromise) await this.reattachPromise; + if (!this.activeSessionId || this.activeSessionId === sentSessionId) throw error; return this.send(method, params, this.activeSessionId); } } @@ -253,6 +256,7 @@ export class Session implements Transport { 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 */ } } @@ -269,10 +273,10 @@ export class Session implements Transport { }); } - private async reattachFirstPage(): Promise { + private async reattachFirstPage(staleSessionId: string): Promise { if (this.reattachPromise) return this.reattachPromise; - const attempt = this.attachFirstPage(); + const attempt = this.attachFirstPage(staleSessionId); this.reattachPromise = attempt; try { await attempt; @@ -281,17 +285,32 @@ export class Session implements Transport { } } - private async attachFirstPage(): Promise { + private async attachFirstPage(staleSessionId: string): Promise { + const domainsToRestore = [...(this.enabledDomains.get(staleSessionId)?.entries() ?? [])]; const { targetInfos } = await this.domains.Target.getTargets({}); const pages = targetInfos as PageTarget[]; const targetId = pages.find(isUsablePageTarget)?.targetId ?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId; const sessionId = await this.use(targetId); - await Promise.allSettled( - ['Page', 'DOM', 'Runtime', 'Network'].map( - domain => this.send(`${domain}.enable`, {}, sessionId), + 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 { @@ -415,7 +434,7 @@ function isUsablePageTarget(target: PageTarget): boolean { && !target.url.startsWith('chrome-untrusted://') && !target.url.startsWith('devtools://') && !target.url.startsWith('chrome-extension://') - && !target.url.startsWith('about:'); + && (!target.url.startsWith('about:') || target.url === 'about:blank'); } /** diff --git a/packages/bcode-browser/test/cdp-recovery.test.ts b/packages/bcode-browser/test/cdp-recovery.test.ts index fb95acdc22..8f5f3a1fe2 100644 --- a/packages/bcode-browser/test/cdp-recovery.test.ts +++ b/packages/bcode-browser/test/cdp-recovery.test.ts @@ -37,7 +37,7 @@ test("a missing page session is reattached once and the rejected command is retr }, 10) return } - if (["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"].includes(message.method)) { + if (message.method === "Debugger.enable") { enabledDomains.push(message.method) socket.send(JSON.stringify({ id: message.id, result: {} })) return @@ -70,6 +70,7 @@ test("a missing page session is reattached once and the rejected command is retr 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" }), @@ -81,7 +82,7 @@ test("a missing page session is reattached once and the rejected command is retr expect(third.result.value).toBe("3") expect(attachCount).toBe(2) expect(getTargetsCount).toBe(1) - expect(enabledDomains).toEqual(["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"]) + expect(enabledDomains).toEqual(["Debugger.enable", "Debugger.enable"]) expect(commandSessions).toEqual([ "session-1", "session-1", @@ -96,6 +97,138 @@ test("a missing page session is reattached once and the rejected command is retr } }) +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("reattach creates a blank page when only internal targets remain", async () => { let attachCount = 0 let createdTarget: unknown From a8186347cfe98cf986fcaa3e595308dcdf7c3a27 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 13:02:46 -0700 Subject: [PATCH 4/4] fix(browser): reattach the intended page --- packages/bcode-browser/src/cdp/session.ts | 22 ++++++++++++++----- .../bcode-browser/test/cdp-recovery.test.ts | 18 ++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 7834d0df97..eaafb25b8f 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -47,6 +47,7 @@ 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> = []; @@ -135,6 +136,7 @@ export class Session implements Transport { const previous = this.ws; this.ws = ws; this.activeSessionId = undefined; + this.activeTargetId = undefined; this.enabledDomains.clear(); finish(); if (previous && previous !== ws) { @@ -148,6 +150,7 @@ export class Session implements Transport { 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)')); @@ -170,12 +173,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 { @@ -228,6 +233,7 @@ export class Session implements Transport { 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) { @@ -236,7 +242,9 @@ export class Session implements Transport { // 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.reattachFirstPage(sentSessionId); + 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); @@ -273,10 +281,10 @@ export class Session implements Transport { }); } - private async reattachFirstPage(staleSessionId: string): Promise { + private async reattachPage(staleSessionId: string, staleTargetId?: string): Promise { if (this.reattachPromise) return this.reattachPromise; - const attempt = this.attachFirstPage(staleSessionId); + const attempt = this.attachPage(staleSessionId, staleTargetId); this.reattachPromise = attempt; try { await attempt; @@ -285,11 +293,15 @@ export class Session implements Transport { } } - private async attachFirstPage(staleSessionId: string): Promise { + private async attachPage(staleSessionId: string, staleTargetId?: string): Promise { const domainsToRestore = [...(this.enabledDomains.get(staleSessionId)?.entries() ?? [])]; const { targetInfos } = await this.domains.Target.getTargets({}); const pages = targetInfos as PageTarget[]; - const targetId = pages.find(isUsablePageTarget)?.targetId + const exactTarget = staleTargetId + ? pages.find(target => target.type === 'page' && target.targetId === staleTargetId) + : undefined; + const targetId = exactTarget?.targetId + ?? (!staleTargetId ? pages.find(isUsablePageTarget)?.targetId : undefined) ?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId; const sessionId = await this.use(targetId); await Promise.all( diff --git a/packages/bcode-browser/test/cdp-recovery.test.ts b/packages/bcode-browser/test/cdp-recovery.test.ts index 8f5f3a1fe2..8a7ceb2a72 100644 --- a/packages/bcode-browser/test/cdp-recovery.test.ts +++ b/packages/bcode-browser/test/cdp-recovery.test.ts @@ -11,6 +11,7 @@ test("a missing page session is reattached once and the rejected command is retr let getTargetsCount = 0 let staleCommandCount = 0 const commandSessions: string[] = [] + const attachedTargets: string[] = [] const enabledDomains: string[] = [] const server = Bun.serve({ port: 0, @@ -22,6 +23,7 @@ test("a missing page session is reattached once and the rejected command is retr 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 } @@ -31,7 +33,10 @@ test("a missing page session is reattached once and the rejected command is retr socket.send(JSON.stringify({ id: message.id, result: { - targetInfos: [{ targetId: "page-1", title: "Page", type: "page", url: "https://example.com" }], + targetInfos: [ + { targetId: "other-page", title: "Other", type: "page", url: "https://other.example" }, + { targetId: "page-1", title: "Page", type: "page", url: "https://example.com" }, + ], }, })) }, 10) @@ -81,6 +86,7 @@ test("a missing page session is reattached once and the rejected command is retr 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([ @@ -229,9 +235,10 @@ test("reattach reuses an existing about:blank target", async () => { } }) -test("reattach creates a blank page when only internal targets remain", async () => { +test("reattach creates a blank page instead of switching to another live target", async () => { let attachCount = 0 let createdTarget: unknown + const attachedTargets: string[] = [] const server = Bun.serve({ port: 0, fetch(req, bunServer) { @@ -242,6 +249,7 @@ test("reattach creates a blank page when only internal targets remain", async () 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 } @@ -249,7 +257,10 @@ test("reattach creates a blank page when only internal targets remain", async () socket.send(JSON.stringify({ id: message.id, result: { - targetInfos: [{ targetId: "settings", title: "Settings", type: "page", url: "chrome://settings" }], + targetInfos: [ + { targetId: "other-page", title: "Other", type: "page", url: "https://other.example" }, + { targetId: "settings", title: "Settings", type: "page", url: "chrome://settings" }, + ], }, })) return @@ -287,6 +298,7 @@ test("reattach creates a blank page when only internal targets remain", async () expect(result.result.value).toBe(true) expect(createdTarget).toEqual({ url: "about:blank" }) expect(attachCount).toBe(2) + expect(attachedTargets).toEqual(["old-page", "fresh-page"]) } finally { session.close() server.stop(true)