Skip to content

Commit f644b02

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

4 files changed

Lines changed: 67 additions & 40 deletions

File tree

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

Lines changed: 11 additions & 18 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

@@ -182,6 +180,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
182180
Effect.suspend(() => {
183181
const session = SessionStore.get(ctx.sessionID)
184182
const captured = { active: true, output: "" }
183+
const sessionExecution = { active: true }
185184
const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
186185
return Effect.gen(function* () {
187186
yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true }))
@@ -248,7 +247,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
248247
})
249248

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

@@ -260,21 +259,16 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
260259
orElse: () =>
261260
Effect.suspend(() => {
262261
captured.active = false
262+
sessionExecution.active = false
263263
const output = timeoutOutput(captured.output)
264264
const error = new Error(
265265
[
266-
`browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`,
266+
`browser_execute timed out after ${timeout} ms; the browser session remains connected`,
267267
output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "",
268268
]
269269
.filter(Boolean)
270270
.join("\n\n"),
271271
)
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)
278272
return Effect.fail(error)
279273
}),
280274
}),
@@ -304,9 +298,8 @@ async function ensureCloudConnected(sessionID: string, session: ReturnType<typeo
304298
await connecting
305299
} finally {
306300
// 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.
301+
// must be surfaced: BU_CDP_WS is the browser selected at run start, not
302+
// necessarily a newer browser the agent explicitly switched to during this run.
310303
v4Bootstrapped.add(sessionID)
311304
if (v4Connections.get(sessionID) === connecting) v4Connections.delete(sessionID)
312305
}

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

Lines changed: 26 additions & 0 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,6 +188,7 @@ export class Session implements Transport {
170188
}
171189

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

@@ -185,6 +204,7 @@ export class Session implements Transport {
185204
* fresh Session.
186205
*/
187206
invalidate(error: Error): void {
207+
assertExecutionActive();
188208
if (this.invalidatedError) return;
189209
this.invalidatedError = error;
190210
const ws = this.ws;
@@ -199,12 +219,14 @@ export class Session implements Transport {
199219
*/
200220
async use(targetId: string): Promise<string> {
201221
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
222+
assertExecutionActive();
202223
this.activeSessionId = r.sessionId;
203224
return r.sessionId;
204225
}
205226

206227
/** Set the active sessionId directly (e.g. one you already attached). */
207228
setActiveSession(sessionId: string | undefined): void {
229+
assertExecutionActive();
208230
this.activeSessionId = sessionId;
209231
}
210232

@@ -214,6 +236,7 @@ export class Session implements Transport {
214236

215237
/** Subscribe to all CDP events. Returns an unsubscribe fn. */
216238
onEvent(fn: (method: string, params: unknown, sessionId?: string) => void): () => void {
239+
assertExecutionActive();
217240
this.eventListeners.push(fn);
218241
return () => {
219242
this.eventListeners = this.eventListeners.filter(x => x !== fn);
@@ -231,6 +254,7 @@ export class Session implements Transport {
231254
* agnostic of any one method's semantics.
232255
*/
233256
onCallResult(fn: (method: string, params: unknown, result: unknown) => void): () => void {
257+
assertExecutionActive();
234258
this.callResultListeners.push(fn);
235259
return () => {
236260
this.callResultListeners = this.callResultListeners.filter(x => x !== fn);
@@ -249,6 +273,7 @@ export class Session implements Transport {
249273
opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {},
250274
...rest: never[]
251275
): Promise<T> {
276+
assertExecutionActive();
252277
// Both legacy positional shapes fail loudly rather than silently reverting
253278
// to the 30s default: `(method, predicate)` lands on the first guard,
254279
// `(method, predicate?, timeoutMs)` on the second. Snippets are written at
@@ -288,6 +313,7 @@ export class Session implements Transport {
288313

289314
// Transport implementation. Called by the generated domain bindings.
290315
_call(method: string, params: unknown = {}): Promise<unknown> {
316+
assertExecutionActive();
291317
if (this.invalidatedError) return Promise.reject(this.invalidatedError);
292318
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
293319
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)