Skip to content

Commit 7708a57

Browse files
authored
Merge pull request #136 from browser-use/timeout-recovery
fix(browser): preserve session after tool timeout
2 parents c4ccff5 + bd975b1 commit 7708a57

6 files changed

Lines changed: 160 additions & 57 deletions

File tree

packages/bcode-browser/skills/browser-execute/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ If a target-scoped command throws `CdpError` code `-32001` (`Session with given
110110
111111
Every explicit reconnect or browser switch retires the previous socket and clears its active target attachment. Re-list targets, call `session.use(...)`, and rediscover DOM nodes and Runtime objects before continuing.
112112
113+
Opening a tab creates a new `page` target but does not switch the active attachment. Call `Target.getTargets` again and `session.use(targetId)` when continuing there.
114+
113115
## Driving a page
114116
Domain methods follow `session.<Domain>.<method>(params)` and return Promises.
115117
The full surface (652 commands) is the Chrome DevTools Protocol.
@@ -200,7 +202,7 @@ console.log(JSON.stringify(titles))
200202
## Guardrails
201203
- Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead.
202204
- No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield.
203-
- `browser_execute` defaults to 60s (max 600s). For longer work, set the tool's top-level `timeout`; inner CDP timeouts do not extend it. Keep batches small and log progress — timeout errors return recent logs, and a timeout resets the CDP session. Reconnect deliberately after a timeout so a run that switched browsers cannot silently return to its original browser.
205+
- `browser_execute` defaults to 60s; longer timeouts delay your next turn. A timeout does not close CDP, though its last command may still run. `Target.getTargets` succeeding means CDP is live; `session.connect()` is then a no-op, and reattaching the same target does not restart its renderer.
204206
205207
## Console
206208
- `console.log`, `console.error`, `console.warn`, `console.info`, `console.debug` are all captured and streamed to the user. Treat them as your stdout. Other `console.*` methods write to bcode's stderr without being captured into the tool result.

packages/bcode-browser/src/browser-execute.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,18 @@
3535
//
3636
// Cancellation: JS Promises are not preemptively cancellable. A snippet
3737
// without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`)
38-
// runs to completion before our timeout fiber observes it. When a yielding
39-
// snippet times out, its Promise keeps running as an orphan — so on timeout
40-
// we retire the exact Session object the snippet received (rejects future
41-
// connect/_call, closes the socket) and evict it from SessionStore. The
42-
// orphan can finish local work but cannot keep driving the browser, and the
43-
// next tool call gets a fresh Session instead of sharing a socket with it.
44-
// The timeout error carries the console output captured so far.
38+
// runs to completion before our timeout fiber observes it. A yielding snippet
39+
// keeps running as an orphan after timeout, so each call receives a scoped
40+
// Session view. The view rejects methods after its deadline while the real
41+
// Session and its tabs remain available to the next call.
4542
//
4643
// Level 1 per decisions.md §1c — substantial implementation lives here. The
4744
// Level-2 hook in packages/opencode is a thin adapter.
4845

4946
import fs from "fs/promises"
5047
import path from "path"
5148
import { Effect, Schema } from "effect"
49+
import { withSessionExecution } from "./cdp/session"
5250
import { SessionStore } from "./session-store"
5351
import { Skills } from "./skills"
5452

@@ -175,13 +173,13 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
175173
const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir))
176174

177175
// Effect values are re-runnable, so per-run state lives inside the suspend
178-
// thunk: each run resolves its own Session (the timeout handler retires
179-
// exactly that object) and its own capture buffer (a re-run after a timeout
180-
// must not inherit a retired Session or a frozen capture).
176+
// thunk: each run gets its own execution scope and capture buffer. A re-run
177+
// after a timeout must not inherit an inactive scope or frozen capture.
181178
const execute = (args: Parameters, ctx: ExecuteContext) =>
182179
Effect.suspend(() => {
183180
const session = SessionStore.get(ctx.sessionID)
184181
const captured = { active: true, output: "" }
182+
const sessionExecution = { active: true }
185183
const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
186184
return Effect.gen(function* () {
187185
yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true }))
@@ -248,7 +246,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
248246
})
249247

250248
const ran = yield* Effect.tryPromise({
251-
try: () => wrapped(session, snippetConsole),
249+
try: () => withSessionExecution(sessionExecution, () => wrapped(session, snippetConsole)),
252250
catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`),
253251
}).pipe(Effect.ensuring(Effect.sync(() => unsubscribe())))
254252

@@ -260,21 +258,16 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
260258
orElse: () =>
261259
Effect.suspend(() => {
262260
captured.active = false
261+
sessionExecution.active = false
263262
const output = timeoutOutput(captured.output)
264263
const error = new Error(
265264
[
266-
`browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`,
265+
`browser_execute timed out after ${timeout} ms; the timeout did not close the CDP session`,
267266
output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "",
268267
]
269268
.filter(Boolean)
270269
.join("\n\n"),
271270
)
272-
// Always retires this snippet's Session; the identity check
273-
// inside only guards the store delete, so a successor Session is
274-
// never evicted. A concurrent same-sessionID call would share the
275-
// retired object — acceptable for v1, opencode serializes tool
276-
// calls within an assistant message.
277-
SessionStore.invalidate(ctx.sessionID, session, error)
278271
return Effect.fail(error)
279272
}),
280273
}),
@@ -304,9 +297,8 @@ async function ensureCloudConnected(sessionID: string, session: ReturnType<typeo
304297
await connecting
305298
} finally {
306299
// One automatic attempt per logical BrowserCode session. A later disconnect
307-
// (including after timeout replacement) must be surfaced:
308-
// BU_CDP_WS is the browser selected at run start, not necessarily a newer
309-
// browser the agent explicitly switched to during this run.
300+
// must be surfaced: BU_CDP_WS is the browser selected at run start, not
301+
// necessarily a newer browser the agent explicitly switched to during this run.
310302
v4Bootstrapped.add(sessionID)
311303
if (v4Connections.get(sessionID) === connecting) v4Connections.delete(sessionID)
312304
}

packages/bcode-browser/src/cdp/session.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,29 @@
66
* Target.sendMessageToTarget envelopes).
77
*/
88

9+
import { AsyncLocalStorage } from 'node:async_hooks';
910
import { bindDomains, type Domains, type Transport } from './generated.ts';
1011

1112
type Pending = {
1213
resolve: (v: unknown) => void;
1314
reject: (e: unknown) => void;
1415
};
1516

17+
export type SessionExecution = { active: boolean };
18+
19+
const sessionExecution = new AsyncLocalStorage<SessionExecution>();
20+
21+
export const withSessionExecution = <T>(
22+
execution: SessionExecution,
23+
run: () => T,
24+
): T => sessionExecution.run(execution, run);
25+
26+
const assertExecutionActive = (): void => {
27+
if (sessionExecution.getStore()?.active === false) {
28+
throw new Error('browser_execute call already timed out');
29+
}
30+
};
31+
1632
export type ConnectOptions = {
1733
/** Full WS URL: ws://host:port/devtools/browser/<id>. Escape hatch. */
1834
wsUrl?: string;
@@ -117,6 +133,7 @@ export class Session implements Transport {
117133
}
118134

119135
private openWs(wsUrl: string, timeoutMs: number): Promise<void> {
136+
assertExecutionActive();
120137
// Re-checked here (not only in connect) because connect awaits resolver/
121138
// detection steps first — an invalidation landing during those must not
122139
// open a late socket for a retired Session.
@@ -170,21 +187,20 @@ export class Session implements Transport {
170187
}
171188

172189
close(): void {
190+
assertExecutionActive();
173191
this.ws?.close();
174192
}
175193

176194
/**
177195
* Permanently retire this Session object.
178196
*
179-
* `browser_execute` timeouts cannot preempt the snippet's Promise — the
180-
* orphan keeps running and would otherwise share this object (and its
181-
* socket) with the next tool call, interleaving two authors on one
182-
* transport. Invalidation rejects all future `connect`/`_call` attempts
183-
* and closes the socket (the close handler rejects in-flight calls);
184-
* `SessionStore.invalidate` removes the entry so the next call gets a
185-
* fresh Session.
197+
* Invalidation rejects all future `connect`/`_call` attempts and closes the
198+
* socket; `SessionStore.invalidate` removes the entry so a later lookup gets
199+
* a fresh Session. `browser_execute` timeouts use scoped execution instead,
200+
* preserving this object and its browser connection for the next call.
186201
*/
187202
invalidate(error: Error): void {
203+
assertExecutionActive();
188204
if (this.invalidatedError) return;
189205
this.invalidatedError = error;
190206
const ws = this.ws;
@@ -199,12 +215,14 @@ export class Session implements Transport {
199215
*/
200216
async use(targetId: string): Promise<string> {
201217
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
218+
assertExecutionActive();
202219
this.activeSessionId = r.sessionId;
203220
return r.sessionId;
204221
}
205222

206223
/** Set the active sessionId directly (e.g. one you already attached). */
207224
setActiveSession(sessionId: string | undefined): void {
225+
assertExecutionActive();
208226
this.activeSessionId = sessionId;
209227
}
210228

@@ -214,9 +232,19 @@ export class Session implements Transport {
214232

215233
/** Subscribe to all CDP events. Returns an unsubscribe fn. */
216234
onEvent(fn: (method: string, params: unknown, sessionId?: string) => void): () => void {
217-
this.eventListeners.push(fn);
235+
assertExecutionActive();
236+
// WebSocket events arrive in the socket's async context, not the context
237+
// where the listener was registered. Restore that registration context so
238+
// callbacks created by a timed-out browser_execute call cannot keep using
239+
// the persistent Session after their execution scope is deactivated.
240+
const execution = sessionExecution.getStore();
241+
const listener = execution
242+
? (method: string, params: unknown, sessionId?: string) =>
243+
sessionExecution.run(execution, () => fn(method, params, sessionId))
244+
: fn;
245+
this.eventListeners.push(listener);
218246
return () => {
219-
this.eventListeners = this.eventListeners.filter(x => x !== fn);
247+
this.eventListeners = this.eventListeners.filter(x => x !== listener);
220248
};
221249
}
222250

@@ -231,9 +259,15 @@ export class Session implements Transport {
231259
* agnostic of any one method's semantics.
232260
*/
233261
onCallResult(fn: (method: string, params: unknown, result: unknown) => void): () => void {
234-
this.callResultListeners.push(fn);
262+
assertExecutionActive();
263+
const execution = sessionExecution.getStore();
264+
const listener = execution
265+
? (method: string, params: unknown, result: unknown) =>
266+
sessionExecution.run(execution, () => fn(method, params, result))
267+
: fn;
268+
this.callResultListeners.push(listener);
235269
return () => {
236-
this.callResultListeners = this.callResultListeners.filter(x => x !== fn);
270+
this.callResultListeners = this.callResultListeners.filter(x => x !== listener);
237271
};
238272
}
239273

@@ -249,6 +283,7 @@ export class Session implements Transport {
249283
opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {},
250284
...rest: never[]
251285
): Promise<T> {
286+
assertExecutionActive();
252287
// Both legacy positional shapes fail loudly rather than silently reverting
253288
// to the 30s default: `(method, predicate)` lands on the first guard,
254289
// `(method, predicate?, timeoutMs)` on the second. Snippets are written at
@@ -288,6 +323,7 @@ export class Session implements Transport {
288323

289324
// Transport implementation. Called by the generated domain bindings.
290325
_call(method: string, params: unknown = {}): Promise<unknown> {
326+
assertExecutionActive();
291327
if (this.invalidatedError) return Promise.reject(this.invalidatedError);
292328
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
293329
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));

packages/bcode-browser/test/browser-auto-connect.test.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ const server = Bun.serve({
3434
typeof request.method !== "string"
3535
)
3636
return;
37+
if (
38+
request.method === "Page.navigate" &&
39+
"params" in request &&
40+
request.params &&
41+
typeof request.params === "object" &&
42+
"url" in request.params &&
43+
request.params.url === "https://stuck.example"
44+
) {
45+
pageCallsWithSession++;
46+
setTimeout(() => ws.send(JSON.stringify({ id: request.id, result: {} })), 40);
47+
return;
48+
}
3749
const result = (() => {
3850
if (request.method === "Target.getTargets")
3951
return {
@@ -276,8 +288,10 @@ test("an explicit browser switch retires the old socket and target attachment",
276288
);
277289
});
278290

279-
test("a timeout replacement does not auto-attach the run-start browser again", async () => {
291+
test("a timeout preserves the same browser and target", async () => {
280292
connections = 0;
293+
attachedCalls = 0;
294+
pageCallsWithSession = 0;
281295
await withEnv(
282296
{ V4_RUN_ID: "run-timeout", BU_CDP_WS: wsUrl, BU_CDP_URL: undefined },
283297
() =>
@@ -287,26 +301,31 @@ test("a timeout replacement does not auto-attach the run-start browser again", a
287301
impl.execute(
288302
{
289303
description: "Time out after initial V4 bootstrap",
290-
code: "await new Promise(resolve => setTimeout(resolve, 100))",
304+
code: `
305+
await session.Page.navigate({ url: "https://stuck.example" })
306+
try { await session.Page.navigate({ url: "https://too-late.example" }) } catch {}
307+
`,
291308
timeout: 10,
292309
},
293310
{ sessionID, workspaceDir },
294311
),
295312
),
296313
).rejects.toThrow("browser_execute timed out");
297314

298-
await expect(
299-
Effect.runPromise(
300-
impl.execute(
301-
{
302-
description: "Do not silently return to the run-start browser",
303-
code: "return await session.Page.navigate({ url: 'https://sap.com' })",
304-
},
305-
{ sessionID, workspaceDir },
306-
),
315+
const recovered = await Effect.runPromise(
316+
impl.execute(
317+
{
318+
description: "Continue on the same browser",
319+
code: "return await session.Page.navigate({ url: 'https://sap.com' })",
320+
},
321+
{ sessionID, workspaceDir },
307322
),
308-
).rejects.toThrow("Not connected. Call session.connect(...) first.");
323+
);
324+
expect(JSON.parse(recovered.result)).toEqual({});
325+
await new Promise((resolve) => setTimeout(resolve, 40));
309326
expect(connections).toBe(1);
327+
expect(attachedCalls).toBe(1);
328+
expect(pageCallsWithSession).toBe(2);
310329
}),
311330
);
312331
});

packages/bcode-browser/test/browser-execute.test.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,9 @@ test("console.debug is captured; uncommon methods fall through without throwing"
225225
})
226226

227227
// Timeout isolation: a timed-out snippet keeps running as an orphan (JS
228-
// Promises are not preemptible), so the tool must retire the Session object
229-
// the snippet received and surface captured output in the error. No Chrome
230-
// required — the snippets sleep without touching the browser.
228+
// Promises are not preemptible), so its scoped Session view must stop working
229+
// while the persistent Session remains available to the next call. Browser
230+
// behavior is covered by browser-auto-connect; these snippets need no Chrome.
231231
const runTimeout = async (id: string, code: string, timeout: number, onChunk?: (o: string) => Effect.Effect<void>) => {
232232
const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-"))
233233
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-ws-"))
@@ -249,7 +249,7 @@ const runTimeout = async (id: string, code: string, timeout: number, onChunk?: (
249249
return err
250250
}
251251

252-
test("timeout returns partial output and retires the session", async () => {
252+
test("timeout returns partial output and preserves the session", async () => {
253253
const id = "timeout-isolation-test"
254254
const before = SessionStore.get(id)
255255
const err = await runTimeout(
@@ -261,12 +261,10 @@ test("timeout returns partial output and retires the session", async () => {
261261
expect(err).toContain("timed out after 100 ms")
262262
expect(err).toContain("Partial console output before timeout:")
263263
expect(err).toContain("progress-marker")
264-
// The orphan's Session is permanently dead...
264+
// The next call gets the exact same persistent Session. The orphan only had
265+
// a scoped view, whose post-timeout CDP behavior is covered by the V4 test.
265266
expect(before.isConnected()).toBe(false)
266-
await expect(before.connect({ wsUrl: "ws://127.0.0.1:9/nope" })).rejects.toThrow(/timed out after 100 ms/)
267-
await expect(before.domains.Runtime.evaluate({ expression: "1" })).rejects.toThrow(/timed out after 100 ms/)
268-
// ...and the next tool call gets a fresh one.
269-
expect(SessionStore.get(id)).not.toBe(before)
267+
expect(SessionStore.get(id)).toBe(before)
270268
await SessionStore.evict(id)
271269
})
272270

@@ -294,7 +292,7 @@ test("re-running the execute effect after a timeout gets fresh state", async ()
294292
const impl = await Effect.runPromise(BrowserExecute.make(data))
295293
// One Effect value, run twice. Each run must resolve its own Session and
296294
// capture buffer — the second run's error must carry its own partial
297-
// output, not inherit the first run's frozen capture or retired Session.
295+
// output, not inherit the first run's frozen capture or scoped view.
298296
// onChunk deliveries discriminate: a run that inherited a frozen capture
299297
// buffer never tees, so it produces zero chunks (the frozen buffer still
300298
// *contains* run 1's text, which is why asserting on the error message

0 commit comments

Comments
 (0)