Skip to content

Commit df76207

Browse files
[v2]: handle target closed errors on popups (#1710)
# why - some popups close so quickly that Stagehand tries to attach a CDP session after the target is already gone, which produces unhandled `Target.attachToTarget` / `newCDPSession` errors - in the case of fast‑closing popups during auto‑init, we want to treat “target gone” as a benign race instead of surfacing as failures # what changed - added shared “target gone” helpers in `lib/utils.ts` - guarded CDP session creation to throw a targeted error when the page/frame is already closed or detached - swallowed that targeted error during page initialization and frame listener attach only when the target is closed or the error indicates the target is gone, so other CDP failures still surface <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Handle rapidly opening/closing popups by detecting “target gone” scenarios and preventing CDP session attach errors. This reduces flakiness during auto-init and keeps fast-closing popups from surfacing as failures. - **Bug Fixes** - Added StagehandTargetClosedError and utils to detect closed/detached targets and CDP “target gone” errors. - Guarded CDP session creation and frame listener attach; swallow only “target closed” errors during init. - Removed cached CDP clients when targets are gone and skipped work on already-closed pages/frames. <sup>Written for commit 6143b9c. Summary will update on new commits. <a href="https://cubic.dev/pr/browserbase/stagehand/pull/1710">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. -->
1 parent 60eef53 commit df76207

File tree

5 files changed

+95
-5
lines changed

5 files changed

+95
-5
lines changed

.changeset/humble-terms-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
handle target closed errors on rapidly opening/closing popups

lib/StagehandContext.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Stagehand } from "./index";
77
import { StagehandPage } from "./StagehandPage";
88
import { Page } from "../types/page";
99
import { EnhancedContext } from "../types/context";
10+
import { StagehandTargetClosedError } from "../types/stagehandErrors";
11+
import { isTargetGoneError } from "./utils";
1012
import { Protocol } from "devtools-protocol";
1113
import { scriptContent } from "./dom/build/scriptContent";
1214

@@ -74,6 +76,9 @@ export class StagehandContext {
7476
private async createStagehandPage(
7577
page: PlaywrightPage,
7678
): Promise<StagehandPage> {
79+
if (page.isClosed()) {
80+
throw new StagehandTargetClosedError();
81+
}
7782
const stagehandPage = await new StagehandPage(
7883
page,
7984
this.stagehand,
@@ -121,10 +126,23 @@ export class StagehandContext {
121126
// Initialize existing pages
122127
const existingPages = context.pages();
123128
for (const page of existingPages) {
124-
const stagehandPage = await instance.createStagehandPage(page);
129+
if (page.isClosed()) continue;
130+
let stagehandPage: StagehandPage | undefined;
131+
try {
132+
stagehandPage = await instance.createStagehandPage(page);
133+
} catch (err) {
134+
if (
135+
err instanceof StagehandTargetClosedError ||
136+
isTargetGoneError(err) ||
137+
page.isClosed()
138+
) {
139+
continue;
140+
}
141+
throw err;
142+
}
125143
await instance.attachFrameNavigatedListener(page);
126144
// Set the first page as active
127-
if (!instance.activeStagehandPage) {
145+
if (!instance.activeStagehandPage && stagehandPage) {
128146
instance.setActivePage(stagehandPage);
129147
}
130148
}
@@ -179,9 +197,21 @@ export class StagehandContext {
179197
}
180198

181199
private async handleNewPlaywrightPage(pwPage: PlaywrightPage): Promise<void> {
200+
if (pwPage.isClosed()) return;
182201
let stagehandPage = this.pageMap.get(pwPage);
183202
if (!stagehandPage) {
184-
stagehandPage = await this.createStagehandPage(pwPage);
203+
try {
204+
stagehandPage = await this.createStagehandPage(pwPage);
205+
} catch (err) {
206+
if (
207+
err instanceof StagehandTargetClosedError ||
208+
isTargetGoneError(err) ||
209+
pwPage.isClosed()
210+
) {
211+
return;
212+
}
213+
throw err;
214+
}
185215
}
186216
this.setActivePage(stagehandPage);
187217
}
@@ -191,7 +221,14 @@ export class StagehandContext {
191221
): Promise<void> {
192222
const shPage = this.pageMap.get(pwPage);
193223
if (!shPage) return;
194-
const session: CDPSession = await this.intContext.newCDPSession(pwPage);
224+
if (pwPage.isClosed()) return;
225+
let session: CDPSession;
226+
try {
227+
session = await this.intContext.newCDPSession(pwPage);
228+
} catch (err) {
229+
if (pwPage.isClosed() || isTargetGoneError(err)) return;
230+
throw err;
231+
}
195232
await session.send("Page.enable");
196233

197234
pwPage.once("close", () => {

lib/StagehandPage.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ActOptions, ActResult, GotoOptions, Stagehand } from "./index";
2020
import { LLMClient } from "./llm/LLMClient";
2121
import { StagehandContext } from "./StagehandContext";
2222
import { EncodedId, EnhancedContext } from "../types/context";
23+
import { isTargetGone, isTargetGoneError } from "./utils";
2324
import {
2425
StagehandError,
2526
StagehandNotInitializedError,
@@ -29,6 +30,7 @@ import {
2930
HandlerNotInitializedError,
3031
StagehandDefaultError,
3132
ExperimentalApiConflictError,
33+
StagehandTargetClosedError,
3234
} from "../types/stagehandErrors";
3335
import { StagehandAPIError } from "@/types/stagehandApiErrors";
3436
import type { Protocol } from "devtools-protocol";
@@ -1049,13 +1051,26 @@ export class StagehandPage {
10491051
target: PlaywrightPage | Frame = this.page,
10501052
): Promise<CDPSession> {
10511053
const cached = this.cdpClients.get(target);
1052-
if (cached) return cached;
1054+
if (cached) {
1055+
if (isTargetGone(target)) {
1056+
this.cdpClients.delete(target);
1057+
throw new StagehandTargetClosedError();
1058+
}
1059+
return cached;
1060+
}
1061+
1062+
if (isTargetGone(target)) {
1063+
throw new StagehandTargetClosedError();
1064+
}
10531065

10541066
try {
10551067
const session = await this.context.newCDPSession(target);
10561068
this.cdpClients.set(target, session);
10571069
return session;
10581070
} catch (err) {
1071+
if (isTargetGone(target) || isTargetGoneError(err)) {
1072+
throw new StagehandTargetClosedError(err);
1073+
}
10591074
// Fallback for same-process iframes
10601075
const msg = (err as Error).message ?? "";
10611076
if (msg.includes("does not have a separate CDP session")) {

lib/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ import { LogLine } from "../types/log";
66
import { ModelProvider } from "../types/model";
77
import { ZodPathSegments } from "../types/stagehand";
88

9+
export const TARGET_GONE_ERROR_SNIPPETS = [
10+
"No target with given id found",
11+
"Target closed",
12+
];
13+
14+
export function isTargetGoneError(err: unknown): boolean {
15+
const msg = err instanceof Error ? err.message : String(err);
16+
return TARGET_GONE_ERROR_SNIPPETS.some((snippet) => msg.includes(snippet));
17+
}
18+
19+
export function isTargetGone(target: {
20+
isClosed?: () => boolean;
21+
isDetached?: () => boolean;
22+
}): boolean {
23+
if (typeof target.isClosed === "function" && target.isClosed()) return true;
24+
return typeof target.isDetached === "function" && target.isDetached();
25+
}
26+
927
export function validateZodSchema(schema: z.ZodTypeAny, data: unknown) {
1028
const result = schema.safeParse(data);
1129

types/stagehandErrors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ export class StagehandNotInitializedError extends StagehandError {
8989
}
9090
}
9191

92+
export class StagehandTargetClosedError extends StagehandError {
93+
public causedBy?: Error | StagehandError;
94+
95+
constructor(error?: unknown) {
96+
const message =
97+
error instanceof Error || error instanceof StagehandError
98+
? `Target closed before CDP session could attach: ${error.message}`
99+
: "Target closed before CDP session could attach";
100+
super(message);
101+
if (error instanceof Error || error instanceof StagehandError) {
102+
this.causedBy = error;
103+
}
104+
}
105+
}
106+
92107
export class BrowserbaseSessionNotFoundError extends StagehandError {
93108
constructor() {
94109
super("No Browserbase session ID found");

0 commit comments

Comments
 (0)