Skip to content

Commit aa1fabe

Browse files
committed
fix(browser): reconnect after tool timeout
1 parent 1ff4926 commit aa1fabe

3 files changed

Lines changed: 121 additions & 24 deletions

File tree

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@
4949
import fs from "fs/promises"
5050
import path from "path"
5151
import { Effect, Schema } from "effect"
52+
import {
53+
getSessionRecoveryState,
54+
type SessionRecoveryState,
55+
} from "./cdp/session"
5256
import { SessionStore } from "./session-store"
5357
import { Skills } from "./skills"
5458

@@ -58,6 +62,7 @@ const MAX_TIMEOUT_OUTPUT_BYTES = 8 * 1024
5862
const TIMEOUT_OUTPUT_TRUNCATED = "[partial console output truncated; showing final bytes]\n"
5963
const v4Connections = new Map<string, Promise<void>>()
6064
const v4Bootstrapped = new Set<string>()
65+
const v4TimeoutRecovery = new Map<string, SessionRecoveryState>()
6166

6267
// Tail-cap the captured output for the timeout error: last 8 KiB, snapped
6368
// forward to a UTF-8 sequence start so multibyte characters survive the cut.
@@ -274,6 +279,9 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
274279
// never evicted. A concurrent same-sessionID call would share the
275280
// retired object — acceptable for v1, opencode serializes tool
276281
// calls within an assistant message.
282+
const recoveryState = getSessionRecoveryState(session)
283+
if (recoveryState) v4TimeoutRecovery.set(ctx.sessionID, recoveryState)
284+
else v4TimeoutRecovery.delete(ctx.sessionID)
277285
SessionStore.invalidate(ctx.sessionID, session, error)
278286
return Effect.fail(error)
279287
}),
@@ -286,27 +294,33 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
286294

287295
async function ensureCloudConnected(sessionID: string, session: ReturnType<typeof SessionStore.get>) {
288296
if (!process.env.V4_RUN_ID || (!process.env.BU_CDP_WS && !process.env.BU_CDP_URL)) return
289-
if (v4Bootstrapped.has(sessionID)) return
297+
const recoveryState = v4TimeoutRecovery.get(sessionID)
298+
if (v4Bootstrapped.has(sessionID) && !recoveryState) return
290299

291300
const existing = v4Connections.get(sessionID)
292301
if (existing) return existing
293302

294303
const connecting = (async () => {
295-
if (!session.isConnected()) await session.connect()
304+
if (!session.isConnected()) {
305+
await session.connect(recoveryState ? { wsUrl: recoveryState.wsUrl } : {})
306+
}
296307
if (session.getActiveSession()) return
297-
const page = (await session.domains.Target.getTargets({})).targetInfos.find(
298-
(target) => target.type === "page" && !target.url.startsWith("chrome://"),
299-
)
308+
const targets = (await session.domains.Target.getTargets({})).targetInfos
309+
const page = targets.find((target) => target.targetId === recoveryState?.targetId)
310+
?? targets.find((target) => target.type === "page" && !target.url.startsWith("chrome://"))
311+
if (recoveryState && !page) {
312+
throw new Error("No page target available after browser_execute timeout")
313+
}
300314
if (page) await session.use(page.targetId)
315+
if (recoveryState) v4TimeoutRecovery.delete(sessionID)
301316
})()
302317
v4Connections.set(sessionID, connecting)
303318
try {
304319
await connecting
305320
} finally {
306-
// 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.
321+
// The initial bootstrap happens once. Only a browser_execute timeout may
322+
// trigger another automatic connection, using the exact endpoint that the
323+
// timed-out Session was driving. Other disconnects remain explicit.
310324
v4Bootstrapped.add(sessionID)
311325
if (v4Connections.get(sessionID) === connecting) v4Connections.delete(sessionID)
312326
}

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ export type ConnectOptions = {
2525
timeoutMs?: number;
2626
};
2727

28+
export type SessionRecoveryState = {
29+
wsUrl: string;
30+
targetId?: string;
31+
};
32+
33+
const recoveryStates = new WeakMap<Session, SessionRecoveryState>();
34+
35+
export const getSessionRecoveryState = (
36+
session: Session,
37+
): SessionRecoveryState | undefined => {
38+
const state = recoveryStates.get(session);
39+
return state ? { ...state } : undefined;
40+
};
41+
2842
/** A Chromium-based browser detected as running on this machine. */
2943
export type DetectedBrowser = {
3044
/** Short label, e.g. 'Google Chrome', 'Brave', 'Comet'. */
@@ -126,6 +140,7 @@ export class Session implements Transport {
126140
const previousWs = this.ws;
127141
this.ws = ws;
128142
this.activeSessionId = undefined;
143+
recoveryStates.delete(this);
129144
if (previousWs) {
130145
for (const [, p] of this.pending) p.reject(new Error('CDP connection replaced'));
131146
this.pending.clear();
@@ -145,6 +160,7 @@ export class Session implements Transport {
145160
finish(new Error('CDP connection superseded'));
146161
return;
147162
}
163+
if (!this.invalidatedError) recoveryStates.set(this, { wsUrl });
148164
finish(this.invalidatedError);
149165
});
150166
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
@@ -170,6 +186,7 @@ export class Session implements Transport {
170186
}
171187

172188
close(): void {
189+
recoveryStates.delete(this);
173190
this.ws?.close();
174191
}
175192

@@ -180,7 +197,7 @@ export class Session implements Transport {
180197
* orphan keeps running and would otherwise share this object (and its
181198
* socket) with the next tool call, interleaving two authors on one
182199
* transport. Invalidation rejects all future `connect`/`_call` attempts
183-
* and closes the socket (the close handler rejects in-flight calls);
200+
* and closes the socket after rejecting in-flight calls;
184201
* `SessionStore.invalidate` removes the entry so the next call gets a
185202
* fresh Session.
186203
*/
@@ -190,6 +207,8 @@ export class Session implements Transport {
190207
const ws = this.ws;
191208
this.ws = undefined;
192209
this.activeSessionId = undefined;
210+
for (const [, p] of this.pending) p.reject(error);
211+
this.pending.clear();
193212
try { ws?.close(); } catch { /* ignore */ }
194213
}
195214

@@ -200,12 +219,16 @@ export class Session implements Transport {
200219
async use(targetId: string): Promise<string> {
201220
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
202221
this.activeSessionId = r.sessionId;
222+
const state = recoveryStates.get(this);
223+
if (state) recoveryStates.set(this, { ...state, targetId });
203224
return r.sessionId;
204225
}
205226

206227
/** Set the active sessionId directly (e.g. one you already attached). */
207228
setActiveSession(sessionId: string | undefined): void {
208229
this.activeSessionId = sessionId;
230+
const state = recoveryStates.get(this);
231+
if (state) recoveryStates.set(this, { wsUrl: state.wsUrl });
209232
}
210233

211234
getActiveSession(): string | undefined {

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

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,22 @@ import { BrowserExecute } from "../src/browser-execute";
77
import { SessionStore } from "../src/session-store";
88

99
let connections = 0;
10+
let alternateConnections = 0;
1011
let closedConnections = 0;
1112
let attachedCalls = 0;
1213
let pageCallsWithSession = 0;
1314
let latestSocket: { close(): void } | undefined;
14-
const server = Bun.serve({
15+
const server = Bun.serve<{ path: string }>({
1516
port: 0,
1617
fetch(req, srv) {
17-
return srv.upgrade(req)
18+
return srv.upgrade(req, { data: { path: new URL(req.url).pathname } })
1819
? undefined
1920
: new Response("upgrade required", { status: 426 });
2021
},
2122
websocket: {
2223
open(ws) {
2324
connections++;
25+
if (ws.data.path === "/alternate") alternateConnections++;
2426
latestSocket = ws;
2527
},
2628
message(ws, message) {
@@ -77,6 +79,7 @@ afterAll(() => {
7779
});
7880

7981
const wsUrl = `ws://127.0.0.1:${server.port}/`;
82+
const alternateWsUrl = `ws://127.0.0.1:${server.port}/alternate`;
8083

8184
const withEnv = async <T>(
8285
vars: Record<string, string | undefined>,
@@ -276,8 +279,10 @@ test("an explicit browser switch retires the old socket and target attachment",
276279
);
277280
});
278281

279-
test("a timeout replacement does not auto-attach the run-start browser again", async () => {
282+
test("a timeout replacement reconnects the same browser and target", async () => {
280283
connections = 0;
284+
attachedCalls = 0;
285+
pageCallsWithSession = 0;
281286
await withEnv(
282287
{ V4_RUN_ID: "run-timeout", BU_CDP_WS: wsUrl, BU_CDP_URL: undefined },
283288
() =>
@@ -295,22 +300,77 @@ test("a timeout replacement does not auto-attach the run-start browser again", a
295300
),
296301
).rejects.toThrow("browser_execute timed out");
297302

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-
),
303+
const recovered = await Effect.runPromise(
304+
impl.execute(
305+
{
306+
description: "Continue on the same browser",
307+
code: "return await session.Page.navigate({ url: 'https://sap.com' })",
308+
},
309+
{ sessionID, workspaceDir },
307310
),
308-
).rejects.toThrow("Not connected. Call session.connect(...) first.");
309-
expect(connections).toBe(1);
311+
);
312+
expect(JSON.parse(recovered.result)).toEqual({});
313+
expect(connections).toBe(2);
314+
expect(attachedCalls).toBe(2);
315+
expect(pageCallsWithSession).toBe(1);
310316
}),
311317
);
312318
});
313319

320+
test("timeout recovery preserves an explicit browser switch", async () => {
321+
connections = 0;
322+
attachedCalls = 0;
323+
alternateConnections = 0;
324+
pageCallsWithSession = 0;
325+
await withEnv(
326+
{
327+
V4_RUN_ID: "run-switched-timeout",
328+
BU_CDP_WS: wsUrl,
329+
BU_CDP_URL: undefined,
330+
},
331+
() =>
332+
withBrowserExecute(
333+
"switched-timeout",
334+
async (impl, sessionID, workspaceDir) => {
335+
const run = (code: string, timeout?: number) =>
336+
Effect.runPromise(
337+
impl.execute(
338+
{ description: "Drive switched browser", code, timeout },
339+
{ sessionID, workspaceDir },
340+
),
341+
);
342+
343+
await run(
344+
"return await session.Page.navigate({ url: 'https://sap.com' })",
345+
);
346+
await run(`
347+
await session.connect({ wsUrl: ${JSON.stringify(alternateWsUrl)} })
348+
const page = (await session.Target.getTargets({})).targetInfos[0]
349+
await session.use(page.targetId)
350+
return await session.Page.navigate({ url: "https://example.com" })
351+
`);
352+
await expect(
353+
run("await new Promise(resolve => setTimeout(resolve, 100))", 10),
354+
).rejects.toThrow("browser_execute timed out");
355+
356+
expect(
357+
JSON.parse(
358+
(
359+
await run(
360+
"return await session.Page.navigate({ url: 'https://example.com/continued' })",
361+
)
362+
).result,
363+
),
364+
).toEqual({});
365+
expect(connections).toBe(3);
366+
expect(alternateConnections).toBe(2);
367+
expect(attachedCalls).toBe(3);
368+
expect(pageCallsWithSession).toBe(3);
369+
},
370+
),
371+
);
372+
});
373+
314374
test("sessions without a provisioned endpoint still require connect", async () => {
315375
await withEnv(
316376
{ V4_RUN_ID: undefined, BU_CDP_WS: undefined, BU_CDP_URL: undefined },

0 commit comments

Comments
 (0)