From d5e86daee16826ef10ac12573606a8abcded27b5 Mon Sep 17 00:00:00 2001 From: ci-test Date: Fri, 30 Jan 2026 13:51:08 -0700 Subject: [PATCH] fix(cdp): avoid unhandled detach by returning original sendCDP promise --- .changeset/bitter-years-fly.md | 5 ++ .../lib/v3/tests/cdp-session-detached.spec.ts | 55 +++++++++++++++++++ packages/core/lib/v3/understudy/cdp.ts | 4 ++ packages/core/lib/v3/understudy/page.ts | 5 +- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/bitter-years-fly.md create mode 100644 packages/core/lib/v3/tests/cdp-session-detached.spec.ts diff --git a/.changeset/bitter-years-fly.md b/.changeset/bitter-years-fly.md new file mode 100644 index 000000000..5ce53f76a --- /dev/null +++ b/.changeset/bitter-years-fly.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +Fix unhandled CDP detaches by returning the original sendCDP promise diff --git a/packages/core/lib/v3/tests/cdp-session-detached.spec.ts b/packages/core/lib/v3/tests/cdp-session-detached.spec.ts new file mode 100644 index 000000000..0bef5b389 --- /dev/null +++ b/packages/core/lib/v3/tests/cdp-session-detached.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; +import { chromium as playwrightChromium } from "playwright"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; + +test.describe("CDP session detach handling", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3(v3TestConfig); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("rejects inflight CDP calls when a target is closed", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + + process.on("unhandledRejection", onUnhandled); + + let pwBrowser: Awaited< + ReturnType + > | null = null; + + try { + pwBrowser = await playwrightChromium.connectOverCDP(v3.connectURL()); + const pwContext = pwBrowser.contexts()[0]; + const pwPage = pwContext.pages()[0]; + + const v3Page = v3.context.pages()[0]; + await v3Page.goto("data:text/html,cdp"); + + const pending = v3Page.sendCDP("Runtime.evaluate", { + expression: "new Promise(r => setTimeout(() => r('done'), 5000))", + awaitPromise: true, + returnByValue: true, + }); + + await pwPage.close(); + + await expect(pending).rejects.toThrow(/CDP session detached/); + + await new Promise((r) => setTimeout(r, 50)); + expect(unhandled).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandled); + await pwBrowser?.close().catch(() => {}); + } + }); +}); diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts index 9ab81f665..b656a45c5 100644 --- a/packages/core/lib/v3/understudy/cdp.ts +++ b/packages/core/lib/v3/understudy/cdp.ts @@ -134,6 +134,8 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); + // Prevent unhandledRejection if a session detaches before the caller awaits. + void p.catch(() => {}); this.cdpLogger?.({ method, params, targetId: null }); this.ws.send(JSON.stringify(payload)); return p; @@ -266,6 +268,8 @@ export class CdpConnection implements CDPSessionLike { ts: Date.now(), }); }); + // Prevent unhandledRejection if a session detaches before the caller awaits. + void p.catch(() => {}); const targetId = this.sessionToTarget.get(sessionId) ?? null; this.cdpLogger?.({ method, params, targetId }); this.ws.send(JSON.stringify(payload)); diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index e8f636629..8d74f73a3 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -607,10 +607,7 @@ export class Page { * { expression: "1 + 1" } * ); */ - public async sendCDP( - method: string, - params?: object, - ): Promise { + public sendCDP(method: string, params?: object): Promise { return this.mainSession.send(method, params); }