Skip to content

Commit d7f3e80

Browse files
committed
fix(browser): preserve session after tool timeout
1 parent 1ff4926 commit d7f3e80

6 files changed

Lines changed: 144 additions & 57 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ console.log(JSON.stringify(titles))
200200
## Guardrails
201201
- Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead.
202202
- 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.
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. After a timeout, the same CDP session and active target are preserved; inspect the current page state in the next call and reconnect only if the socket actually closed.
204204
205205
## Console
206206
- `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 browser session remains connected`,
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: 48 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;
@@ -80,6 +96,7 @@ export class Session implements Transport {
8096
* and we connect directly to the supplied endpoint.
8197
*/
8298
async connect(opts: ConnectOptions = {}): Promise<void> {
99+
assertExecutionActive();
83100
if (this.invalidatedError) throw this.invalidatedError;
84101
const timeoutMs = opts.timeoutMs ?? 5_000;
85102
if (opts.wsUrl || opts.profileDir) {
@@ -117,6 +134,7 @@ export class Session implements Transport {
117134
}
118135

119136
private openWs(wsUrl: string, timeoutMs: number): Promise<void> {
137+
assertExecutionActive();
120138
// Re-checked here (not only in connect) because connect awaits resolver/
121139
// detection steps first — an invalidation landing during those must not
122140
// open a late socket for a retired Session.
@@ -170,21 +188,20 @@ export class Session implements Transport {
170188
}
171189

172190
close(): void {
191+
assertExecutionActive();
173192
this.ws?.close();
174193
}
175194

176195
/**
177196
* Permanently retire this Session object.
178197
*
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.
198+
* Invalidation rejects all future `connect`/`_call` attempts and closes the
199+
* socket; `SessionStore.invalidate` removes the entry so a later lookup gets
200+
* a fresh Session. `browser_execute` timeouts use scoped execution instead,
201+
* preserving this object and its browser connection for the next call.
186202
*/
187203
invalidate(error: Error): void {
204+
assertExecutionActive();
188205
if (this.invalidatedError) return;
189206
this.invalidatedError = error;
190207
const ws = this.ws;
@@ -199,12 +216,14 @@ export class Session implements Transport {
199216
*/
200217
async use(targetId: string): Promise<string> {
201218
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
219+
assertExecutionActive();
202220
this.activeSessionId = r.sessionId;
203221
return r.sessionId;
204222
}
205223

206224
/** Set the active sessionId directly (e.g. one you already attached). */
207225
setActiveSession(sessionId: string | undefined): void {
226+
assertExecutionActive();
208227
this.activeSessionId = sessionId;
209228
}
210229

@@ -214,9 +233,19 @@ export class Session implements Transport {
214233

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

@@ -231,9 +260,15 @@ export class Session implements Transport {
231260
* agnostic of any one method's semantics.
232261
*/
233262
onCallResult(fn: (method: string, params: unknown, result: unknown) => void): () => void {
234-
this.callResultListeners.push(fn);
263+
assertExecutionActive();
264+
const execution = sessionExecution.getStore();
265+
const listener = execution
266+
? (method: string, params: unknown, result: unknown) =>
267+
sessionExecution.run(execution, () => fn(method, params, result))
268+
: fn;
269+
this.callResultListeners.push(listener);
235270
return () => {
236-
this.callResultListeners = this.callResultListeners.filter(x => x !== fn);
271+
this.callResultListeners = this.callResultListeners.filter(x => x !== listener);
237272
};
238273
}
239274

@@ -249,6 +284,7 @@ export class Session implements Transport {
249284
opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {},
250285
...rest: never[]
251286
): Promise<T> {
287+
assertExecutionActive();
252288
// Both legacy positional shapes fail loudly rather than silently reverting
253289
// to the 30s default: `(method, predicate)` lands on the first guard,
254290
// `(method, predicate?, timeoutMs)` on the second. Snippets are written at
@@ -288,6 +324,7 @@ export class Session implements Transport {
288324

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

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,10 @@ test("an explicit browser switch retires the old socket and target attachment",
276276
);
277277
});
278278

279-
test("a timeout replacement does not auto-attach the run-start browser again", async () => {
279+
test("a timeout preserves the same browser and target", async () => {
280280
connections = 0;
281+
attachedCalls = 0;
282+
pageCallsWithSession = 0;
281283
await withEnv(
282284
{ V4_RUN_ID: "run-timeout", BU_CDP_WS: wsUrl, BU_CDP_URL: undefined },
283285
() =>
@@ -287,26 +289,34 @@ test("a timeout replacement does not auto-attach the run-start browser again", a
287289
impl.execute(
288290
{
289291
description: "Time out after initial V4 bootstrap",
290-
code: "await new Promise(resolve => setTimeout(resolve, 100))",
292+
code: `
293+
const navigate = session.Page.navigate
294+
setTimeout(async () => {
295+
try { await navigate({ url: "https://too-late.example" }) } catch {}
296+
}, 30)
297+
await new Promise(resolve => setTimeout(resolve, 100))
298+
`,
291299
timeout: 10,
292300
},
293301
{ sessionID, workspaceDir },
294302
),
295303
),
296304
).rejects.toThrow("browser_execute timed out");
297305

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-
),
306+
const recovered = await Effect.runPromise(
307+
impl.execute(
308+
{
309+
description: "Continue on the same browser",
310+
code: "return await session.Page.navigate({ url: 'https://sap.com' })",
311+
},
312+
{ sessionID, workspaceDir },
307313
),
308-
).rejects.toThrow("Not connected. Call session.connect(...) first.");
314+
);
315+
expect(JSON.parse(recovered.result)).toEqual({});
316+
await new Promise((resolve) => setTimeout(resolve, 40));
309317
expect(connections).toBe(1);
318+
expect(attachedCalls).toBe(1);
319+
expect(pageCallsWithSession).toBe(1);
310320
}),
311321
);
312322
});

‎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)