Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/steady-page-evaluation.md
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.
170 changes: 170 additions & 0 deletions packages/extension/tests/page-evaluate-promise-lifetime.test.ts
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" },
});
});
});
45 changes: 40 additions & 5 deletions packages/extension/understudy/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface FrameManager {
pageId: string;
}

const RETURN_REMOTE_OBJECT_BY_VALUE = "function() { return this; }";

/**
* Frame
*
Expand Down Expand Up @@ -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
Expand All @@ -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);

Copy link
Copy Markdown
Contributor

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 retained objectId outside the try/catch that retries Runtime.evaluate on "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 whole evaluate rejects. 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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/understudy/frame.ts, line 181:

<comment>The new materialization step (`Runtime.awaitPromise`/`Runtime.callFunctionOn`) runs against the retained `objectId` outside the try/catch that retries `Runtime.evaluate` on "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 whole `evaluate` rejects. 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.</comment>

<file context>
@@ -170,16 +174,47 @@ export class Frame implements FrameManager {
+        returnByValue: false,
       });
     }
+    res = await this.materializeEvaluationResult(res);
     if (res.exceptionDetails) {
-      throw new Error(res.exceptionDetails.text ?? "Evaluation failed");
</file context>

if (res.exceptionDetails) {
throw new Error(res.exceptionDetails.text ?? "Evaluation failed");
throw new Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Exception and error message sanitization

Frame.evaluate throws a generic new Error(...) that bubbles to users through Page.evaluate. Replace it with a typed error class and remove the raw exceptionDetails.exception?.description fallback, which surfaces unsanitized JavaScript exception text to the user.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/understudy/frame.ts, line 183:

<comment>`Frame.evaluate` throws a generic `new Error(...)` that bubbles to users through `Page.evaluate`. Replace it with a typed error class and remove the raw `exceptionDetails.exception?.description` fallback, which surfaces unsanitized JavaScript exception text to the user.</comment>

<file context>
@@ -170,16 +174,47 @@ export class Frame implements FrameManager {
+    res = await this.materializeEvaluationResult(res);
     if (res.exceptionDetails) {
-      throw new Error(res.exceptionDetails.text ?? "Evaluation failed");
+      throw new Error(
+        res.exceptionDetails.text ||
+          res.exceptionDetails.exception?.description ||
</file context>

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(() => {});
Expand Down
39 changes: 1 addition & 38 deletions packages/extension/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,44 +1359,7 @@ export class Page {
pageFunctionOrExpression: string | ((arg: Arg) => R | Promise<R>),
arg?: Arg,
): Promise<R> {
await this.mainSession.send("Runtime.enable").catch(() => {});
const ctxId = await this.mainWorldExecutionContextId();

const isString = typeof pageFunctionOrExpression === "string";
let expression: string;

if (isString) {
expression = String(pageFunctionOrExpression);
} else {
const fnSrc = pageFunctionOrExpression.toString();
const argJson = JSON.stringify(arg);
expression = `(() => {
const __fn = ${fnSrc};
const __arg = ${argJson};
try {
const __res = __fn(__arg);
return Promise.resolve(__res).then(v => {
try { return JSON.parse(JSON.stringify(v)); } catch { return v; }
});
} catch (e) { throw e; }
})()`;
}

const { result, exceptionDetails } =
await this.mainSession.send<Protocol.Runtime.EvaluateResponse>("Runtime.evaluate", {
expression,
contextId: ctxId,
returnByValue: true,
awaitPromise: true,
});

if (exceptionDetails) {
const msg =
exceptionDetails.text || exceptionDetails.exception?.description || "Evaluation failed";
throw new Error(msg);
}

return result?.value as R;
return this.mainFrameWrapper.evaluate(pageFunctionOrExpression, arg);
}

/**
Expand Down
Binary file modified packages/sdk-go/internal/extensionassets/stagehand-extension.zip
Binary file not shown.