Skip to content

Commit 00781d5

Browse files
authored
Merge pull request #126 from browser-use/timeout-isolation
fix(browser): retire timed-out snippet sessions and return partial output
2 parents 3106007 + b1f6512 commit 00781d5

6 files changed

Lines changed: 247 additions & 16 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ console.log(JSON.stringify(titles))
197197
## Guardrails
198198
- Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead.
199199
- No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield.
200+
- `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 in the next snippet).
200201
201202
## Console
202203
- `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: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@
3232
//
3333
// Cancellation: JS Promises are not preemptively cancellable. A snippet
3434
// without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`)
35-
// runs to completion before our timeout fiber observes it. `Effect.timeoutOrElse`
36-
// fails the surrounding fiber but the orphan Promise keeps running until it
37-
// finishes. This matches the `uv run` subprocess case (SIGTERM only after
38-
// the Python signal handler yields). Document, don't fix.
35+
// runs to completion before our timeout fiber observes it. When a yielding
36+
// snippet times out, its Promise keeps running as an orphan — so on timeout
37+
// we retire the exact Session object the snippet received (rejects future
38+
// connect/_call, closes the socket) and evict it from SessionStore. The
39+
// orphan can finish local work but cannot keep driving the browser, and the
40+
// next tool call gets a fresh Session instead of sharing a socket with it.
41+
// The timeout error carries the console output captured so far.
3942
//
4043
// Level 1 per decisions.md §1c — substantial implementation lives here. The
4144
// Level-2 hook in packages/opencode is a thin adapter.
@@ -48,6 +51,18 @@ import { Skills } from "./skills"
4851

4952
const DEFAULT_TIMEOUT_MS = 60 * 1000
5053
const MAX_TIMEOUT_MS = 10 * 60 * 1000
54+
const MAX_TIMEOUT_OUTPUT_BYTES = 8 * 1024
55+
const TIMEOUT_OUTPUT_TRUNCATED = "[partial console output truncated; showing final bytes]\n"
56+
57+
// Tail-cap the captured output for the timeout error: last 8 KiB, snapped
58+
// forward to a UTF-8 sequence start so multibyte characters survive the cut.
59+
const timeoutOutput = (output: string) => {
60+
const bytes = Buffer.from(output, "utf8")
61+
if (bytes.length <= MAX_TIMEOUT_OUTPUT_BYTES) return output
62+
let start = bytes.length - (MAX_TIMEOUT_OUTPUT_BYTES - Buffer.byteLength(TIMEOUT_OUTPUT_TRUNCATED))
63+
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++
64+
return TIMEOUT_OUTPUT_TRUNCATED + bytes.subarray(start).toString("utf8")
65+
}
5166

5267
// Field order matters: providers stream tool-call args in schema-declared
5368
// order, so the model commits to whichever field comes first. `code` is the
@@ -157,20 +172,27 @@ const serialize = (v: unknown): string => {
157172
export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) {
158173
const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir))
159174

175+
// Effect values are re-runnable, so per-run state lives inside the suspend
176+
// thunk: each run resolves its own Session (the timeout handler retires
177+
// exactly that object) and its own capture buffer (a re-run after a timeout
178+
// must not inherit a retired Session or a frozen capture).
160179
const execute = (args: Parameters, ctx: ExecuteContext) =>
161-
Effect.gen(function* () {
162-
const session = SessionStore.get(ctx.sessionID)
180+
Effect.suspend(() => {
181+
const session = SessionStore.get(ctx.sessionID)
182+
const captured = { active: true, output: "" }
183+
const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)
184+
return Effect.gen(function* () {
163185
yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true }))
164186

165187
const wrapped = yield* Effect.try({
166188
try: () => new AsyncFunction("session", "console", args.code),
167189
catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`),
168190
})
169191

170-
let output = ""
171192
const tee = (...a: unknown[]) => {
172-
output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n"
173-
if (ctx.onChunk) Effect.runFork(ctx.onChunk(output))
193+
if (!captured.active) return
194+
captured.output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n"
195+
if (ctx.onChunk) Effect.runFork(ctx.onChunk(captured.output))
174196
}
175197
// Prototype-chain to the real `console` so uncommon methods (`debug`,
176198
// `dir`, `trace`, `table`, `group`, …) don't throw when a snippet calls
@@ -223,14 +245,34 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
223245
catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`),
224246
}).pipe(Effect.ensuring(Effect.sync(() => unsubscribe())))
225247

226-
return { output, result: serialize(ran), screenshots } satisfies ExecuteResult
248+
return { output: captured.output, result: serialize(ran), screenshots } satisfies ExecuteResult
227249
}).pipe(
228250
Effect.scoped,
229251
Effect.timeoutOrElse({
230-
duration: Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS),
231-
orElse: () => Effect.fail(new Error("browser_execute timed out")),
252+
duration: timeout,
253+
orElse: () =>
254+
Effect.suspend(() => {
255+
captured.active = false
256+
const output = timeoutOutput(captured.output)
257+
const error = new Error(
258+
[
259+
`browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`,
260+
output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "",
261+
]
262+
.filter(Boolean)
263+
.join("\n\n"),
264+
)
265+
// Always retires this snippet's Session; the identity check
266+
// inside only guards the store delete, so a successor Session is
267+
// never evicted. A concurrent same-sessionID call would share the
268+
// retired object — acceptable for v1, opencode serializes tool
269+
// calls within an assistant message.
270+
SessionStore.invalidate(ctx.sessionID, session, error)
271+
return Effect.fail(error)
272+
}),
232273
}),
233274
)
275+
})
234276

235277
return { parameters, execute, skillsDir }
236278
})

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

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type DetectedBrowser = {
4343

4444
export class Session implements Transport {
4545
private ws?: WebSocket;
46+
private invalidatedError?: Error;
4647
private nextId = 1;
4748
private pending = new Map<number, Pending>();
4849
private activeSessionId: string | undefined;
@@ -79,6 +80,7 @@ export class Session implements Transport {
7980
* and we connect directly to the supplied endpoint.
8081
*/
8182
async connect(opts: ConnectOptions = {}): Promise<void> {
83+
if (this.invalidatedError) throw this.invalidatedError;
8284
const timeoutMs = opts.timeoutMs ?? 5_000;
8385
if (opts.wsUrl || opts.profileDir) {
8486
const wsUrl = await resolveWsUrl(opts, timeoutMs);
@@ -103,6 +105,7 @@ export class Session implements Transport {
103105
await this.openWs(b.wsUrl, timeoutMs);
104106
return;
105107
} catch (e) {
108+
if (this.invalidatedError) throw this.invalidatedError;
106109
const msg = e instanceof Error ? e.message : String(e);
107110
errors.push(` ${b.name} @ ${b.wsUrl}: ${msg}`);
108111
}
@@ -113,6 +116,10 @@ export class Session implements Transport {
113116
}
114117

115118
private openWs(wsUrl: string, timeoutMs: number): Promise<void> {
119+
// Re-checked here (not only in connect) because connect awaits resolver/
120+
// detection steps first — an invalidation landing during those must not
121+
// open a late socket for a retired Session.
122+
if (this.invalidatedError) return Promise.reject(this.invalidatedError);
116123
return new Promise<void>((res, rej) => {
117124
const ws = new WebSocket(wsUrl);
118125
let done = false;
@@ -124,26 +131,46 @@ export class Session implements Transport {
124131
else res();
125132
};
126133
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
127-
ws.addEventListener('open', () => finish());
134+
ws.addEventListener('open', () => finish(this.invalidatedError));
128135
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
129136
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
130137
ws.addEventListener('close', () => {
131-
for (const [, p] of this.pending) p.reject(new Error('CDP socket closed'));
138+
for (const [, p] of this.pending) p.reject(this.invalidatedError ?? new Error('CDP socket closed'));
132139
this.pending.clear();
133-
finish(new Error('WS closed before open (likely 403 or port closed)'));
140+
finish(this.invalidatedError ?? new Error('WS closed before open (likely 403 or port closed)'));
134141
});
135142
this.ws = ws;
136143
});
137144
}
138145

139146
isConnected(): boolean {
140-
return this.ws?.readyState === WebSocket.OPEN;
147+
return !this.invalidatedError && this.ws?.readyState === WebSocket.OPEN;
141148
}
142149

143150
close(): void {
144151
this.ws?.close();
145152
}
146153

154+
/**
155+
* Permanently retire this Session object.
156+
*
157+
* `browser_execute` timeouts cannot preempt the snippet's Promise — the
158+
* orphan keeps running and would otherwise share this object (and its
159+
* socket) with the next tool call, interleaving two authors on one
160+
* transport. Invalidation rejects all future `connect`/`_call` attempts
161+
* and closes the socket (the close handler rejects in-flight calls);
162+
* `SessionStore.invalidate` removes the entry so the next call gets a
163+
* fresh Session.
164+
*/
165+
invalidate(error: Error): void {
166+
if (this.invalidatedError) return;
167+
this.invalidatedError = error;
168+
const ws = this.ws;
169+
this.ws = undefined;
170+
this.activeSessionId = undefined;
171+
try { ws?.close(); } catch { /* ignore */ }
172+
}
173+
147174
/**
148175
* Pick a target and make subsequent calls auto-route to it.
149176
* Uses Target.attachToTarget with flatten:true (single-WS, sessionId-on-message).
@@ -239,6 +266,7 @@ export class Session implements Transport {
239266

240267
// Transport implementation. Called by the generated domain bindings.
241268
_call(method: string, params: unknown = {}): Promise<unknown> {
269+
if (this.invalidatedError) return Promise.reject(this.invalidatedError);
242270
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
243271
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));
244272
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export const get = (sessionID: string): Session => {
2727
return fresh
2828
}
2929

30+
// Retire a specific Session object after a browser_execute timeout. The
31+
// expected Session is always invalidated; the identity check only guards the
32+
// map delete, so a stale caller can never evict a successor Session that a
33+
// newer call is already using.
34+
export const invalidate = (sessionID: string, expected: Session, error: Error): void => {
35+
if (sessions.get(sessionID) === expected) sessions.delete(sessionID)
36+
expected.invalidate(error)
37+
}
38+
3039
export const evict = async (sessionID: string): Promise<void> => {
3140
const entry = sessions.get(sessionID)
3241
if (!entry) return

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,130 @@ test("console.debug is captured; uncommon methods fall through without throwing"
224224
await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true })))
225225
})
226226

227+
// 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.
231+
const runTimeout = async (id: string, code: string, timeout: number, onChunk?: (o: string) => Effect.Effect<void>) => {
232+
const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-"))
233+
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-ws-"))
234+
const err = await Effect.runPromise(
235+
Effect.scoped(
236+
Effect.gen(function* () {
237+
const impl = yield* BrowserExecute.make(data)
238+
return yield* impl.execute(
239+
{ description: "timeout test", code, timeout },
240+
{ sessionID: id, workspaceDir: ws, onChunk },
241+
)
242+
}),
243+
),
244+
).then(
245+
() => { throw new Error("expected timeout") },
246+
(e: unknown) => String(e),
247+
)
248+
await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true })))
249+
return err
250+
}
251+
252+
test("timeout returns partial output and retires the session", async () => {
253+
const id = "timeout-isolation-test"
254+
const before = SessionStore.get(id)
255+
const err = await runTimeout(
256+
id,
257+
`console.log("progress-marker");
258+
await new Promise((r) => setTimeout(r, 60_000));`,
259+
100,
260+
)
261+
expect(err).toContain("timed out after 100 ms")
262+
expect(err).toContain("Partial console output before timeout:")
263+
expect(err).toContain("progress-marker")
264+
// The orphan's Session is permanently dead...
265+
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)
270+
await SessionStore.evict(id)
271+
})
272+
273+
test("console capture and onChunk stop after timeout", async () => {
274+
const chunks: string[] = []
275+
const err = await runTimeout(
276+
"timeout-capture-test",
277+
`console.log("early");
278+
await new Promise((r) => setTimeout(r, 250));
279+
console.log("late");`,
280+
100,
281+
(o) => Effect.sync(() => { chunks.push(o) }),
282+
)
283+
expect(err).toContain("early")
284+
// Let the orphan's late log fire, then confirm it was not captured.
285+
await new Promise((r) => setTimeout(r, 400))
286+
expect(chunks.some((c) => c.includes("early"))).toBe(true)
287+
expect(chunks.some((c) => c.includes("late"))).toBe(false)
288+
await SessionStore.evict("timeout-capture-test")
289+
})
290+
291+
test("re-running the execute effect after a timeout gets fresh state", async () => {
292+
const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-rerun-"))
293+
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-rerun-ws-"))
294+
const impl = await Effect.runPromise(BrowserExecute.make(data))
295+
// One Effect value, run twice. Each run must resolve its own Session and
296+
// 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.
298+
// onChunk deliveries discriminate: a run that inherited a frozen capture
299+
// buffer never tees, so it produces zero chunks (the frozen buffer still
300+
// *contains* run 1's text, which is why asserting on the error message
301+
// alone cannot catch this).
302+
const chunks: string[] = []
303+
const eff = impl.execute(
304+
{
305+
description: "rerun test",
306+
code: `console.log("progress-marker");
307+
await new Promise((r) => setTimeout(r, 60_000));`,
308+
timeout: 100,
309+
},
310+
{ sessionID: "rerun-test", workspaceDir: ws, onChunk: (o) => Effect.sync(() => { chunks.push(o) }) },
311+
)
312+
const run = () => Effect.runPromise(eff).then(() => "resolved", (e: unknown) => String(e))
313+
const first = await run()
314+
const afterFirst = chunks.length
315+
const second = await run()
316+
expect(first).toContain("progress-marker")
317+
expect(second).toContain("progress-marker")
318+
expect(afterFirst).toBeGreaterThan(0)
319+
expect(chunks.length).toBeGreaterThan(afterFirst)
320+
await SessionStore.evict("rerun-test")
321+
await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true })))
322+
})
323+
324+
test("invalidate retires the expected Session even after replacement", async () => {
325+
const id = "invalidate-replaced-test"
326+
const s1 = SessionStore.get(id)
327+
await SessionStore.evict(id)
328+
const s2 = SessionStore.get(id)
329+
SessionStore.invalidate(id, s1, new Error("retired stale session"))
330+
// The successor entry is untouched, but the stale object is still dead.
331+
expect(SessionStore.get(id)).toBe(s2)
332+
await expect(s1.connect({ wsUrl: "ws://127.0.0.1:9/nope" })).rejects.toThrow(/retired stale session/)
333+
await SessionStore.evict(id)
334+
})
335+
336+
test("timeout output is tail-capped to valid UTF-8", async () => {
337+
// ~25 KiB of multibyte lines, all logged before the sleep.
338+
const err = await runTimeout(
339+
"timeout-truncate-test",
340+
`for (let i = 0; i < 300; i++) console.log("é".repeat(40) + "-line-" + i);
341+
await new Promise((r) => setTimeout(r, 60_000));`,
342+
100,
343+
)
344+
expect(err).toContain("[partial console output truncated; showing final bytes]")
345+
expect(err).toContain("-line-299")
346+
expect(err).not.toContain("-line-0\n")
347+
expect(err).not.toContain("\uFFFD")
348+
await SessionStore.evict("timeout-truncate-test")
349+
})
350+
227351
// Concurrency safety: two overlapping execute() calls (different sessionIDs)
228352
// must each capture their own console output without leaking into each other
229353
// or into the real global console. No Chrome required — the snippets never

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,30 @@ test("waitFor throws on a positional timeout rather than silently using the 30s
7777
await expect(session.waitFor("Test.never", { timeoutMs: 50 })).rejects.toThrow(/Timeout waiting for/)
7878
expect(Date.now() - started).toBeLessThan(1_000)
7979
})
80+
81+
// Retirement guarantee under in-flight connects: an invalidation landing
82+
// while connect() is between awaits must not leave a usable or open socket.
83+
test("invalidate before the socket exists rejects the in-flight connect", async () => {
84+
const s = new Session()
85+
// connect() awaits resolveWsUrl before openWs, so invalidate() runs while
86+
// no socket exists yet — openWs must refuse to create one afterwards.
87+
const connecting = s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/`, timeoutMs: 1_000 })
88+
s.invalidate(new Error("retired by test"))
89+
await expect(connecting).rejects.toThrow("retired by test")
90+
expect(s.isConnected()).toBe(false)
91+
})
92+
93+
test("invalidate while the socket is connecting closes it and rejects", async () => {
94+
const s = new Session()
95+
const connecting = s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/`, timeoutMs: 1_000 })
96+
// Yield one macrotask so openWs has created the WebSocket, then retire.
97+
await Bun.sleep(0)
98+
s.invalidate(new Error("retired by test"))
99+
// Depending on whether the open event won the race, connect either rejects
100+
// or resolved just before retirement — in both cases the Session must end
101+
// dead with no usable transport.
102+
await connecting.catch(() => {})
103+
expect(s.isConnected()).toBe(false)
104+
await expect(s._call("Runtime.evaluate", { expression: "1" })).rejects.toThrow("retired by test")
105+
await expect(s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` })).rejects.toThrow("retired by test")
106+
})

0 commit comments

Comments
 (0)