-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix: prevent page.evaluate promises from being collected #2751
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| "@browserbasehq/stagehand": patch | ||
| "@browserbasehq/stagehand-python": patch | ||
| "@browserbasehq/stagehand-go": patch | ||
| "@browserbasehq/stagehand-extension": patch | ||
| --- | ||
|
|
||
| Keep page evaluation results alive while Chrome awaits and serializes them, preventing intermittent `Promise was collected` failures during page churn. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import type { Protocol } from "devtools-protocol"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import type { StagehandLogger } from "../logger.js"; | ||
| import type { CDPSessionLike, CdpConnection } from "../understudy/cdp.js"; | ||
| import { executionContexts } from "../understudy/executionContextRegistry.js"; | ||
| import { Page } from "../understudy/page.js"; | ||
|
|
||
| type CDPCall = { method: string; params?: object }; | ||
| type SendHandler = (method: string, params?: object) => unknown; | ||
|
|
||
| class EvaluationSession implements CDPSessionLike { | ||
| private static nextId = 1; | ||
| readonly id = `evaluation-session-${EvaluationSession.nextId++}`; | ||
| readonly calls: CDPCall[] = []; | ||
|
|
||
| constructor(private readonly handler: SendHandler) {} | ||
|
|
||
| async send<Result = unknown>(method: string, params?: object): Promise<Result> { | ||
| this.calls.push({ method, params }); | ||
| return (await this.handler(method, params)) as Result; | ||
| } | ||
|
|
||
| on(): void {} | ||
|
|
||
| off(): void {} | ||
|
|
||
| async close(): Promise<void> {} | ||
| } | ||
|
|
||
| function createPage(session: EvaluationSession): Page { | ||
| executionContexts.register(session, "frame-a", 7); | ||
| return new Page({} as CdpConnection, session, "page-a", "frame-a", {} as StagehandLogger); | ||
| } | ||
|
|
||
| function runtimeMethods(session: EvaluationSession): string[] { | ||
| return session.calls | ||
| .map(({ method }) => method) | ||
| .filter((method) => method.startsWith("Runtime.")); | ||
| } | ||
|
|
||
| describe("Page.evaluate", () => { | ||
| it("retains a returned promise by object id while awaiting its value", async () => { | ||
| const session = new EvaluationSession((method, params) => { | ||
| if (method === "Runtime.enable") return {}; | ||
| if (method === "Runtime.evaluate") { | ||
| expect(params).toMatchObject({ | ||
| contextId: 7, | ||
| awaitPromise: false, | ||
| returnByValue: false, | ||
| }); | ||
| return { | ||
| result: { | ||
| type: "object", | ||
| subtype: "promise", | ||
| className: "Promise", | ||
| description: "Promise", | ||
| objectId: "promise-1", | ||
| }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| if (method === "Runtime.awaitPromise") { | ||
| expect(params).toStrictEqual({ | ||
| promiseObjectId: "promise-1", | ||
| returnByValue: true, | ||
| }); | ||
| return { | ||
| result: { type: "object", value: { answer: 42 } }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| if (method === "Runtime.releaseObject") { | ||
| expect(params).toStrictEqual({ objectId: "promise-1" }); | ||
| return {}; | ||
| } | ||
| throw new Error(`Unexpected CDP method ${method}`); | ||
| }); | ||
|
|
||
| await expect(createPage(session).evaluate("Promise.resolve({ answer: 42 })")).resolves.toEqual({ | ||
| answer: 42, | ||
| }); | ||
| expect(runtimeMethods(session)).toStrictEqual([ | ||
| "Runtime.enable", | ||
| "Runtime.evaluate", | ||
| "Runtime.awaitPromise", | ||
| "Runtime.releaseObject", | ||
| ]); | ||
| }); | ||
|
|
||
| it("serializes a synchronous object through its retained remote handle", async () => { | ||
| const session = new EvaluationSession((method, params) => { | ||
| if (method === "Runtime.enable") return {}; | ||
| if (method === "Runtime.evaluate") { | ||
| return { | ||
| result: { | ||
| type: "object", | ||
| className: "Object", | ||
| description: "Object", | ||
| objectId: "object-1", | ||
| }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| if (method === "Runtime.callFunctionOn") { | ||
| expect(params).toStrictEqual({ | ||
| objectId: "object-1", | ||
| functionDeclaration: "function() { return this; }", | ||
| returnByValue: true, | ||
| }); | ||
| return { | ||
| result: { type: "object", value: { answer: 42 } }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| if (method === "Runtime.releaseObject") return {}; | ||
| throw new Error(`Unexpected CDP method ${method}`); | ||
| }); | ||
|
|
||
| await expect(createPage(session).evaluate("({ answer: 42 })")).resolves.toEqual({ | ||
| answer: 42, | ||
| }); | ||
| expect(runtimeMethods(session)).toStrictEqual([ | ||
| "Runtime.enable", | ||
| "Runtime.evaluate", | ||
| "Runtime.callFunctionOn", | ||
| "Runtime.releaseObject", | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns primitive results without creating a remote handle", async () => { | ||
| const session = new EvaluationSession((method) => { | ||
| if (method === "Runtime.enable") return {}; | ||
| if (method === "Runtime.evaluate") { | ||
| return { | ||
| result: { type: "number", value: 42 }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| throw new Error(`Unexpected CDP method ${method}`); | ||
| }); | ||
|
|
||
| await expect(createPage(session).evaluate("40 + 2")).resolves.toBe(42); | ||
| expect(runtimeMethods(session)).toStrictEqual(["Runtime.enable", "Runtime.evaluate"]); | ||
| }); | ||
|
|
||
| it("releases the retained object when promise materialization fails", async () => { | ||
| const session = new EvaluationSession((method) => { | ||
| if (method === "Runtime.enable") return {}; | ||
| if (method === "Runtime.evaluate") { | ||
| return { | ||
| result: { | ||
| type: "object", | ||
| subtype: "promise", | ||
| className: "Promise", | ||
| description: "Promise", | ||
| objectId: "promise-1", | ||
| }, | ||
| } satisfies Protocol.Runtime.EvaluateResponse; | ||
| } | ||
| if (method === "Runtime.awaitPromise") { | ||
| throw new Error("await failed"); | ||
| } | ||
| if (method === "Runtime.releaseObject") return {}; | ||
| throw new Error(`Unexpected CDP method ${method}`); | ||
| }); | ||
|
|
||
| await expect(createPage(session).evaluate("new Promise(() => {})")).rejects.toThrow( | ||
| "await failed", | ||
| ); | ||
| expect(session.calls.at(-1)).toStrictEqual({ | ||
| method: "Runtime.releaseObject", | ||
| params: { objectId: "promise-1" }, | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,8 @@ interface FrameManager { | |
| pageId: string; | ||
| } | ||
|
|
||
| const RETURN_REMOTE_OBJECT_BY_VALUE = "function() { return this; }"; | ||
|
|
||
| /** | ||
| * Frame | ||
| * | ||
|
|
@@ -155,11 +157,13 @@ export class Frame implements FrameManager { | |
|
|
||
| let res: Protocol.Runtime.EvaluateResponse; | ||
| try { | ||
| // Keep object results alive across evaluation and serialization. Asking | ||
| // Runtime.evaluate to await directly can let V8 collect the promise first. | ||
| res = await this.session.send<Protocol.Runtime.EvaluateResponse>("Runtime.evaluate", { | ||
| expression, | ||
| contextId, | ||
| awaitPromise: true, | ||
| returnByValue: true, | ||
| awaitPromise: false, | ||
| returnByValue: false, | ||
| }); | ||
| } catch (error) { | ||
| // Execution contexts can be recreated between context lookup and | ||
|
|
@@ -170,16 +174,47 @@ export class Frame implements FrameManager { | |
| res = await this.session.send<Protocol.Runtime.EvaluateResponse>("Runtime.evaluate", { | ||
| expression, | ||
| contextId: freshContextId, | ||
| awaitPromise: true, | ||
| returnByValue: true, | ||
| awaitPromise: false, | ||
| returnByValue: false, | ||
| }); | ||
| } | ||
| res = await this.materializeEvaluationResult(res); | ||
| if (res.exceptionDetails) { | ||
| throw new Error(res.exceptionDetails.text ?? "Evaluation failed"); | ||
| throw new Error( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Exception and error message sanitization
Prompt for AI agents |
||
| res.exceptionDetails.text || | ||
| res.exceptionDetails.exception?.description || | ||
| "Evaluation failed", | ||
| ); | ||
| } | ||
| return res.result.value as R; | ||
| } | ||
|
|
||
| private async materializeEvaluationResult( | ||
| response: Protocol.Runtime.EvaluateResponse, | ||
| ): Promise<Protocol.Runtime.EvaluateResponse> { | ||
| const objectId = response.result.objectId; | ||
| if (!objectId) return response; | ||
|
|
||
| try { | ||
| if (response.exceptionDetails) return response; | ||
|
|
||
| if (response.result.subtype === "promise") { | ||
| return await this.session.send<Protocol.Runtime.EvaluateResponse>("Runtime.awaitPromise", { | ||
| promiseObjectId: objectId, | ||
| returnByValue: true, | ||
| }); | ||
| } | ||
|
|
||
| return await this.session.send<Protocol.Runtime.EvaluateResponse>("Runtime.callFunctionOn", { | ||
| objectId, | ||
| functionDeclaration: RETURN_REMOTE_OBJECT_BY_VALUE, | ||
| returnByValue: true, | ||
| }); | ||
| } finally { | ||
| await this.session.send("Runtime.releaseObject", { objectId }).catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| /** Evaluate an internal expression in Stagehand's selected locator world. */ | ||
| async evaluateInLocatorWorld<R = unknown>(expression: string): Promise<R> { | ||
| await this.session.send("Runtime.enable").catch(() => {}); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The new materialization step (
Runtime.awaitPromise/Runtime.callFunctionOn) runs against the retainedobjectIdoutside the try/catch that retriesRuntime.evaluateon "Cannot find context with specified id". When page churn recreates the context between the evaluate and this follow-up call, the follow-up fails with no retry, so the wholeevaluaterejects. Consider wrapping the materialize step with the same context-recreation retry (re-running evaluate with a fresh context id) or documenting why a retry is intentionally omitted for the non-evaluate round-trips.Prompt for AI agents