From 170aca79e962db5f5cb96e4b7336a2248f26dd80 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Mon, 29 Jun 2026 11:38:34 -0700 Subject: [PATCH 1/8] clean up session handlers --- packages/core/lib/v3/understudy/cdp.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index ffda2f267c..9bdeaf9474 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -208,6 +208,15 @@ export class CdpConnection implements CDPSessionLike { } } + private clearSessionEventHandlers(sessionId: string): void { + const prefix = `${sessionId}:`; + for (const key of Array.from(this.eventHandlers.keys())) { + if (key.startsWith(prefix)) { + this.eventHandlers.delete(key); + } + } + } + getSession(sessionId: string): CdpSession | undefined { return this.sessions.get(sessionId); } @@ -353,6 +362,7 @@ export class CdpConnection implements CDPSessionLike { ); } } + this.clearSessionEventHandlers(p.sessionId); this.sessions.delete(p.sessionId); this.sessionToTarget.delete(p.sessionId); this.latestCdpCallEvent.delete(p.sessionId); @@ -361,6 +371,7 @@ export class CdpConnection implements CDPSessionLike { // Remove any session mapping for this target for (const [sessionId, targetId] of this.sessionToTarget.entries()) { if (targetId === p.targetId) { + this.clearSessionEventHandlers(sessionId); this.sessionToTarget.delete(sessionId); this.latestCdpCallEvent.delete(sessionId); break; From 71f050525fb3c90ce549b5db3f31446ec2da8d06 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Mon, 29 Jun 2026 11:38:45 -0700 Subject: [PATCH 2/8] add test --- .../tests/unit/cdp-connection-close.test.ts | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/unit/cdp-connection-close.test.ts b/packages/core/tests/unit/cdp-connection-close.test.ts index 6418d5b431..d51aad576d 100644 --- a/packages/core/tests/unit/cdp-connection-close.test.ts +++ b/packages/core/tests/unit/cdp-connection-close.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { WebSocketServer, type WebSocket as ServerWebSocket } from "ws"; import { CdpConnection } from "../../lib/v3/understudy/cdp.js"; +type ConnectionInternals = { + eventHandlers: Map>; +}; + /** * Races a promise against a timeout. Returns "resolved" if the promise * settles before the deadline, or "timeout" if it doesn't. @@ -40,11 +44,22 @@ async function createPair(): Promise<{ return { conn, serverSocket, wss }; } +async function sendCdpEvent( + serverSocket: ServerWebSocket, + message: Record, +): Promise { + serverSocket.send(JSON.stringify(message)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe("CdpConnection", () => { let wss: WebSocketServer | null = null; afterEach(async () => { if (wss) { + for (const client of wss.clients) { + client.terminate(); + } await new Promise((resolve) => wss!.close(() => resolve())); wss = null; } @@ -98,4 +113,129 @@ describe("CdpConnection", () => { expect(result).toBe("rejected"); }); }); + + describe("session event listener cleanup", () => { + it("removes session-scoped event handlers when a target detaches", async () => { + const pair = await createPair(); + wss = pair.wss; + + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId: "session-a", + targetInfo: { + targetId: "target-a", + type: "page", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + + const session = pair.conn.getSession("session-a"); + expect(session).toBeDefined(); + + session!.on("Fetch.requestPaused", () => {}); + session!.on("Network.requestWillBeSent", () => {}); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId: "session-b", + targetInfo: { + targetId: "target-b", + type: "iframe", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + + const otherSession = pair.conn.getSession("session-b"); + expect(otherSession).toBeDefined(); + otherSession!.on("Fetch.requestPaused", () => {}); + + const rootHandler = vi.fn(); + pair.conn.on("Target.targetCreated", rootHandler); + + const eventHandlers = (pair.conn as unknown as ConnectionInternals) + .eventHandlers; + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(true); + expect(eventHandlers.has("session-a:Network.requestWillBeSent")).toBe( + true, + ); + expect(eventHandlers.has("session-b:Fetch.requestPaused")).toBe(true); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.detachedFromTarget", + params: { + sessionId: "session-a", + targetId: "target-a", + }, + }); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.targetCreated", + params: { + targetInfo: { + targetId: "target-b", + type: "page", + title: "", + url: "about:blank", + attached: false, + canAccessOpener: false, + }, + }, + }); + + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(false); + expect(eventHandlers.has("session-a:Network.requestWillBeSent")).toBe( + false, + ); + expect(eventHandlers.has("session-b:Fetch.requestPaused")).toBe(true); + expect(eventHandlers.has("Target.targetCreated")).toBe(true); + expect(rootHandler).toHaveBeenCalledOnce(); + }); + + it("removes session-scoped event handlers when a target is destroyed", async () => { + const pair = await createPair(); + wss = pair.wss; + + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId: "session-a", + targetInfo: { + targetId: "target-a", + type: "page", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + + const session = pair.conn.getSession("session-a"); + expect(session).toBeDefined(); + session!.on("Fetch.requestPaused", () => {}); + + const eventHandlers = (pair.conn as unknown as ConnectionInternals) + .eventHandlers; + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(true); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.targetDestroyed", + params: { + targetId: "target-a", + }, + }); + + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(false); + }); + }); }); From 1d7e608e52599cb96a2544a312b122c8ea27ba3c Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Mon, 29 Jun 2026 11:40:02 -0700 Subject: [PATCH 3/8] changeset --- .changeset/petite-rocks-lead.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/petite-rocks-lead.md diff --git a/.changeset/petite-rocks-lead.md b/.changeset/petite-rocks-lead.md new file mode 100644 index 0000000000..b09047e029 --- /dev/null +++ b/.changeset/petite-rocks-lead.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +clean up cdp session event handlers on target detach From 8009f5536b33952e8d654638110d380022aeb956 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 1 Jul 2026 13:20:32 -0700 Subject: [PATCH 4/8] delete cdp session on target destroyed --- packages/core/lib/v3/understudy/cdp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index 9bdeaf9474..cb27335d9b 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -372,6 +372,7 @@ export class CdpConnection implements CDPSessionLike { for (const [sessionId, targetId] of this.sessionToTarget.entries()) { if (targetId === p.targetId) { this.clearSessionEventHandlers(sessionId); + this.sessions.delete(sessionId); this.sessionToTarget.delete(sessionId); this.latestCdpCallEvent.delete(sessionId); break; From f9880f6bc6f4475a7162d541df9e1bd0492cab6e Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 1 Jul 2026 13:25:19 -0700 Subject: [PATCH 5/8] address comment --- packages/core/lib/v3/understudy/cdp.ts | 1 - .../tests/unit/cdp-connection-close.test.ts | 45 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index cb27335d9b..051be62a5c 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -375,7 +375,6 @@ export class CdpConnection implements CDPSessionLike { this.sessions.delete(sessionId); this.sessionToTarget.delete(sessionId); this.latestCdpCallEvent.delete(sessionId); - break; } } } diff --git a/packages/core/tests/unit/cdp-connection-close.test.ts b/packages/core/tests/unit/cdp-connection-close.test.ts index d51aad576d..cc3886c16d 100644 --- a/packages/core/tests/unit/cdp-connection-close.test.ts +++ b/packages/core/tests/unit/cdp-connection-close.test.ts @@ -237,5 +237,50 @@ describe("CdpConnection", () => { expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(false); }); + + it("removes all session-scoped event handlers for a destroyed target", async () => { + const pair = await createPair(); + wss = pair.wss; + + for (const sessionId of ["session-a", "session-b"]) { + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId, + targetInfo: { + targetId: "target-a", + type: "page", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + } + + const sessionA = pair.conn.getSession("session-a"); + const sessionB = pair.conn.getSession("session-b"); + expect(sessionA).toBeDefined(); + expect(sessionB).toBeDefined(); + + sessionA!.on("Fetch.requestPaused", () => {}); + sessionB!.on("Fetch.requestPaused", () => {}); + + const eventHandlers = (pair.conn as unknown as ConnectionInternals) + .eventHandlers; + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(true); + expect(eventHandlers.has("session-b:Fetch.requestPaused")).toBe(true); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.targetDestroyed", + params: { + targetId: "target-a", + }, + }); + + expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(false); + expect(eventHandlers.has("session-b:Fetch.requestPaused")).toBe(false); + }); }); }); From 63540dcd418cabdbde8f708598af2b3f47f01e68 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 1 Jul 2026 13:28:09 -0700 Subject: [PATCH 6/8] test removal of multiple handlers on single sess --- packages/core/tests/unit/cdp-connection-close.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/tests/unit/cdp-connection-close.test.ts b/packages/core/tests/unit/cdp-connection-close.test.ts index cc3886c16d..3a52bfcc7f 100644 --- a/packages/core/tests/unit/cdp-connection-close.test.ts +++ b/packages/core/tests/unit/cdp-connection-close.test.ts @@ -137,7 +137,10 @@ describe("CdpConnection", () => { const session = pair.conn.getSession("session-a"); expect(session).toBeDefined(); - session!.on("Fetch.requestPaused", () => {}); + const fetchHandlerA = vi.fn(); + const fetchHandlerB = vi.fn(); + session!.on("Fetch.requestPaused", fetchHandlerA); + session!.on("Fetch.requestPaused", fetchHandlerB); session!.on("Network.requestWillBeSent", () => {}); await sendCdpEvent(pair.serverSocket, { @@ -165,6 +168,7 @@ describe("CdpConnection", () => { const eventHandlers = (pair.conn as unknown as ConnectionInternals) .eventHandlers; expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(true); + expect(eventHandlers.get("session-a:Fetch.requestPaused")?.size).toBe(2); expect(eventHandlers.has("session-a:Network.requestWillBeSent")).toBe( true, ); From 70b7dbcf850664b16aac2696c7e28c5289a11456 Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 1 Jul 2026 13:54:19 -0700 Subject: [PATCH 7/8] reject in flight .send on target destroyed --- packages/core/lib/v3/understudy/cdp.ts | 46 ++++++---- .../tests/unit/cdp-connection-close.test.ts | 87 ++++++++++++++++++- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index 051be62a5c..a268e5662e 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -217,6 +217,31 @@ export class CdpConnection implements CDPSessionLike { } } + private rejectSessionPendingWork( + sessionId: string, + targetId: string | null, + ): void { + for (const [id, entry] of this.inflight.entries()) { + if (entry.sessionId === sessionId) { + entry.reject( + new PageNotFoundError( + `target closed before CDP response (sessionId=${sessionId}, targetId=${targetId})`, + ), + ); + this.inflight.delete(id); + } + } + for (const waiter of Array.from(this.sessionDispatchWaiters)) { + if (waiter.sessionId === sessionId) { + waiter.reject( + new PageNotFoundError( + `target closed before CDP send (sessionId=${sessionId}, targetId=${targetId})`, + ), + ); + } + } + } + getSession(sessionId: string): CdpSession | undefined { return this.sessions.get(sessionId); } @@ -343,25 +368,7 @@ export class CdpConnection implements CDPSessionLike { } else if (msg.method === "Target.detachedFromTarget") { const p = (msg as { params: Protocol.Target.DetachedFromTargetEvent }) .params; - for (const [id, entry] of this.inflight.entries()) { - if (entry.sessionId === p.sessionId) { - entry.reject( - new PageNotFoundError( - `target closed before CDP response (sessionId=${p.sessionId}, targetId=${p.targetId})`, - ), - ); - this.inflight.delete(id); - } - } - for (const waiter of Array.from(this.sessionDispatchWaiters)) { - if (waiter.sessionId === p.sessionId) { - waiter.reject( - new PageNotFoundError( - `target closed before CDP send (sessionId=${p.sessionId}, targetId=${p.targetId})`, - ), - ); - } - } + this.rejectSessionPendingWork(p.sessionId, p.targetId ?? null); this.clearSessionEventHandlers(p.sessionId); this.sessions.delete(p.sessionId); this.sessionToTarget.delete(p.sessionId); @@ -371,6 +378,7 @@ export class CdpConnection implements CDPSessionLike { // Remove any session mapping for this target for (const [sessionId, targetId] of this.sessionToTarget.entries()) { if (targetId === p.targetId) { + this.rejectSessionPendingWork(sessionId, p.targetId); this.clearSessionEventHandlers(sessionId); this.sessions.delete(sessionId); this.sessionToTarget.delete(sessionId); diff --git a/packages/core/tests/unit/cdp-connection-close.test.ts b/packages/core/tests/unit/cdp-connection-close.test.ts index 3a52bfcc7f..bd887b3728 100644 --- a/packages/core/tests/unit/cdp-connection-close.test.ts +++ b/packages/core/tests/unit/cdp-connection-close.test.ts @@ -48,7 +48,12 @@ async function sendCdpEvent( serverSocket: ServerWebSocket, message: Record, ): Promise { - serverSocket.send(JSON.stringify(message)); + await new Promise((resolve, reject) => { + serverSocket.send(JSON.stringify(message), (error) => { + if (error) reject(error); + else resolve(); + }); + }); await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -286,5 +291,85 @@ describe("CdpConnection", () => { expect(eventHandlers.has("session-a:Fetch.requestPaused")).toBe(false); expect(eventHandlers.has("session-b:Fetch.requestPaused")).toBe(false); }); + + it("rejects in-flight session sends when a target is destroyed", async () => { + const pair = await createPair(); + wss = pair.wss; + + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId: "session-a", + targetInfo: { + targetId: "target-a", + type: "page", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + + const session = pair.conn.getSession("session-a"); + expect(session).toBeDefined(); + + const pending = session!.send("Runtime.evaluate", { + expression: "1+1", + }); + const resultPromise = pending + .then(() => "resolved") + .catch(() => "rejected"); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.targetDestroyed", + params: { + targetId: "target-a", + }, + }); + + const result = await raceTimeout(resultPromise, 3_000); + + expect(result).toBe("rejected"); + }); + + it("rejects session dispatch waiters when a target is destroyed", async () => { + const pair = await createPair(); + wss = pair.wss; + + await sendCdpEvent(pair.serverSocket, { + method: "Target.attachedToTarget", + params: { + sessionId: "session-a", + targetInfo: { + targetId: "target-a", + type: "page", + title: "", + url: "about:blank", + attached: true, + canAccessOpener: false, + }, + }, + }); + + const pending = pair.conn.waitForSessionDispatch( + "session-a", + "Fetch.enable", + ); + const resultPromise = pending + .then(() => "resolved") + .catch(() => "rejected"); + + await sendCdpEvent(pair.serverSocket, { + method: "Target.targetDestroyed", + params: { + targetId: "target-a", + }, + }); + + const result = await raceTimeout(resultPromise, 3_000); + + expect(result).toBe("rejected"); + }); }); }); From 5e7d04a34062f0afd2313701eb6b020fa4863e6a Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Wed, 1 Jul 2026 14:38:41 -0700 Subject: [PATCH 8/8] fix test --- .../tests/unit/cdp-connection-close.test.ts | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/core/tests/unit/cdp-connection-close.test.ts b/packages/core/tests/unit/cdp-connection-close.test.ts index bd887b3728..fc60d670f0 100644 --- a/packages/core/tests/unit/cdp-connection-close.test.ts +++ b/packages/core/tests/unit/cdp-connection-close.test.ts @@ -57,6 +57,29 @@ async function sendCdpEvent( await new Promise((resolve) => setTimeout(resolve, 0)); } +async function waitForSession( + conn: CdpConnection, + sessionId: string, +): Promise> { + const session = await raceTimeout( + new Promise>((resolve) => { + const check = () => { + const found = conn.getSession(sessionId); + if (found) { + resolve(found); + return; + } + setTimeout(check, 0); + }; + check(); + }), + 3_000, + ); + + expect(session).not.toBe("timeout"); + return session as ReturnType; +} + describe("CdpConnection", () => { let wss: WebSocketServer | null = null; @@ -139,8 +162,7 @@ describe("CdpConnection", () => { }, }); - const session = pair.conn.getSession("session-a"); - expect(session).toBeDefined(); + const session = await waitForSession(pair.conn, "session-a"); const fetchHandlerA = vi.fn(); const fetchHandlerB = vi.fn(); @@ -163,8 +185,7 @@ describe("CdpConnection", () => { }, }); - const otherSession = pair.conn.getSession("session-b"); - expect(otherSession).toBeDefined(); + const otherSession = await waitForSession(pair.conn, "session-b"); otherSession!.on("Fetch.requestPaused", () => {}); const rootHandler = vi.fn(); @@ -229,8 +250,7 @@ describe("CdpConnection", () => { }, }); - const session = pair.conn.getSession("session-a"); - expect(session).toBeDefined(); + const session = await waitForSession(pair.conn, "session-a"); session!.on("Fetch.requestPaused", () => {}); const eventHandlers = (pair.conn as unknown as ConnectionInternals) @@ -268,10 +288,8 @@ describe("CdpConnection", () => { }); } - const sessionA = pair.conn.getSession("session-a"); - const sessionB = pair.conn.getSession("session-b"); - expect(sessionA).toBeDefined(); - expect(sessionB).toBeDefined(); + const sessionA = await waitForSession(pair.conn, "session-a"); + const sessionB = await waitForSession(pair.conn, "session-b"); sessionA!.on("Fetch.requestPaused", () => {}); sessionB!.on("Fetch.requestPaused", () => {}); @@ -311,8 +329,7 @@ describe("CdpConnection", () => { }, }); - const session = pair.conn.getSession("session-a"); - expect(session).toBeDefined(); + const session = await waitForSession(pair.conn, "session-a"); const pending = session!.send("Runtime.evaluate", { expression: "1+1",