From b743e99f447b8822e0a3542485e5f9a61d98ba7c Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sat, 1 Aug 2026 22:50:08 -0700 Subject: [PATCH 1/8] fix(browser): preserve session after tool timeout --- .../skills/browser-execute/SKILL.md | 4 +- packages/bcode-browser/src/browser-execute.ts | 34 ++++------- packages/bcode-browser/src/cdp/session.ts | 59 +++++++++++++++---- .../test/browser-auto-connect.test.ts | 34 +++++++---- .../test/browser-execute.test.ts | 18 +++--- .../bcode-browser/test/cdp-session.test.ts | 54 ++++++++++++++++- 6 files changed, 146 insertions(+), 57 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 27c8b4d5c..c1fdcf797 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -110,6 +110,8 @@ If a target-scoped command throws `CdpError` code `-32001` (`Session with given 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. +New tabs and windows are separate targets with `type: "page"`; opening one does not move the current attachment, so `Page`/`Runtime` commands still go to the old tab and waiting for a page event does not switch to the new one. If the task continues in the new tab, re-list targets, identify the intended page by URL, title, or `targetId` rather than list position or an iframe/OOPIF target, then call `session.use(targetId)`. + ## Driving a page Domain methods follow `session..(params)` and return Promises. The full surface (652 commands) is the Chrome DevTools Protocol. @@ -200,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `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. +- `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. ## Console - `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. diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 1e341cc5d..b0fb5b87d 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -35,13 +35,10 @@ // // Cancellation: JS Promises are not preemptively cancellable. A snippet // without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`) -// runs to completion before our timeout fiber observes it. When a yielding -// snippet times out, its Promise keeps running as an orphan — so on timeout -// we retire the exact Session object the snippet received (rejects future -// connect/_call, closes the socket) and evict it from SessionStore. The -// orphan can finish local work but cannot keep driving the browser, and the -// next tool call gets a fresh Session instead of sharing a socket with it. -// The timeout error carries the console output captured so far. +// runs to completion before our timeout fiber observes it. A yielding snippet +// keeps running as an orphan after timeout, so each call receives a scoped +// Session view. The view rejects methods after its deadline while the real +// Session and its tabs remain available to the next call. // // Level 1 per decisions.md §1c — substantial implementation lives here. The // Level-2 hook in packages/opencode is a thin adapter. @@ -49,6 +46,7 @@ import fs from "fs/promises" import path from "path" import { Effect, Schema } from "effect" +import { withSessionExecution } from "./cdp/session" import { SessionStore } from "./session-store" import { Skills } from "./skills" @@ -175,13 +173,13 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir)) // Effect values are re-runnable, so per-run state lives inside the suspend - // thunk: each run resolves its own Session (the timeout handler retires - // exactly that object) and its own capture buffer (a re-run after a timeout - // must not inherit a retired Session or a frozen capture). + // thunk: each run gets its own execution scope and capture buffer. A re-run + // after a timeout must not inherit an inactive scope or frozen capture. const execute = (args: Parameters, ctx: ExecuteContext) => Effect.suspend(() => { const session = SessionStore.get(ctx.sessionID) const captured = { active: true, output: "" } + const sessionExecution = { active: true } const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) return Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true })) @@ -248,7 +246,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) }) const ran = yield* Effect.tryPromise({ - try: () => wrapped(session, snippetConsole), + try: () => withSessionExecution(sessionExecution, () => wrapped(session, snippetConsole)), catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`), }).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))) @@ -260,21 +258,16 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) orElse: () => Effect.suspend(() => { captured.active = false + sessionExecution.active = false const output = timeoutOutput(captured.output) const error = new Error( [ - `browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`, + `browser_execute timed out after ${timeout} ms; the browser session remains connected`, output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "", ] .filter(Boolean) .join("\n\n"), ) - // Always retires this snippet's Session; the identity check - // inside only guards the store delete, so a successor Session is - // never evicted. A concurrent same-sessionID call would share the - // retired object — acceptable for v1, opencode serializes tool - // calls within an assistant message. - SessionStore.invalidate(ctx.sessionID, session, error) return Effect.fail(error) }), }), @@ -304,9 +297,8 @@ async function ensureCloudConnected(sessionID: string, session: ReturnType void; }; +export type SessionExecution = { active: boolean }; + +const sessionExecution = new AsyncLocalStorage(); + +export const withSessionExecution = ( + execution: SessionExecution, + run: () => T, +): T => sessionExecution.run(execution, run); + +const assertExecutionActive = (): void => { + if (sessionExecution.getStore()?.active === false) { + throw new Error('browser_execute call already timed out'); + } +}; + export type ConnectOptions = { /** Full WS URL: ws://host:port/devtools/browser/. Escape hatch. */ wsUrl?: string; @@ -80,6 +96,7 @@ export class Session implements Transport { * and we connect directly to the supplied endpoint. */ async connect(opts: ConnectOptions = {}): Promise { + assertExecutionActive(); if (this.invalidatedError) throw this.invalidatedError; const timeoutMs = opts.timeoutMs ?? 5_000; if (opts.wsUrl || opts.profileDir) { @@ -117,6 +134,7 @@ export class Session implements Transport { } private openWs(wsUrl: string, timeoutMs: number): Promise { + assertExecutionActive(); // Re-checked here (not only in connect) because connect awaits resolver/ // detection steps first — an invalidation landing during those must not // open a late socket for a retired Session. @@ -170,21 +188,20 @@ export class Session implements Transport { } close(): void { + assertExecutionActive(); this.ws?.close(); } /** * Permanently retire this Session object. * - * `browser_execute` timeouts cannot preempt the snippet's Promise — the - * orphan keeps running and would otherwise share this object (and its - * socket) with the next tool call, interleaving two authors on one - * transport. Invalidation rejects all future `connect`/`_call` attempts - * and closes the socket (the close handler rejects in-flight calls); - * `SessionStore.invalidate` removes the entry so the next call gets a - * fresh Session. + * Invalidation rejects all future `connect`/`_call` attempts and closes the + * socket; `SessionStore.invalidate` removes the entry so a later lookup gets + * a fresh Session. `browser_execute` timeouts use scoped execution instead, + * preserving this object and its browser connection for the next call. */ invalidate(error: Error): void { + assertExecutionActive(); if (this.invalidatedError) return; this.invalidatedError = error; const ws = this.ws; @@ -199,12 +216,14 @@ export class Session implements Transport { */ async use(targetId: string): Promise { const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string }; + assertExecutionActive(); this.activeSessionId = r.sessionId; return r.sessionId; } /** Set the active sessionId directly (e.g. one you already attached). */ setActiveSession(sessionId: string | undefined): void { + assertExecutionActive(); this.activeSessionId = sessionId; } @@ -214,9 +233,19 @@ export class Session implements Transport { /** Subscribe to all CDP events. Returns an unsubscribe fn. */ onEvent(fn: (method: string, params: unknown, sessionId?: string) => void): () => void { - this.eventListeners.push(fn); + assertExecutionActive(); + // WebSocket events arrive in the socket's async context, not the context + // where the listener was registered. Restore that registration context so + // callbacks created by a timed-out browser_execute call cannot keep using + // the persistent Session after their execution scope is deactivated. + const execution = sessionExecution.getStore(); + const listener = execution + ? (method: string, params: unknown, sessionId?: string) => + sessionExecution.run(execution, () => fn(method, params, sessionId)) + : fn; + this.eventListeners.push(listener); return () => { - this.eventListeners = this.eventListeners.filter(x => x !== fn); + this.eventListeners = this.eventListeners.filter(x => x !== listener); }; } @@ -231,9 +260,15 @@ export class Session implements Transport { * agnostic of any one method's semantics. */ onCallResult(fn: (method: string, params: unknown, result: unknown) => void): () => void { - this.callResultListeners.push(fn); + assertExecutionActive(); + const execution = sessionExecution.getStore(); + const listener = execution + ? (method: string, params: unknown, result: unknown) => + sessionExecution.run(execution, () => fn(method, params, result)) + : fn; + this.callResultListeners.push(listener); return () => { - this.callResultListeners = this.callResultListeners.filter(x => x !== fn); + this.callResultListeners = this.callResultListeners.filter(x => x !== listener); }; } @@ -249,6 +284,7 @@ export class Session implements Transport { opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {}, ...rest: never[] ): Promise { + assertExecutionActive(); // Both legacy positional shapes fail loudly rather than silently reverting // to the 30s default: `(method, predicate)` lands on the first guard, // `(method, predicate?, timeoutMs)` on the second. Snippets are written at @@ -288,6 +324,7 @@ export class Session implements Transport { // Transport implementation. Called by the generated domain bindings. _call(method: string, params: unknown = {}): Promise { + assertExecutionActive(); if (this.invalidatedError) return Promise.reject(this.invalidatedError); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error('Not connected. Call session.connect(...) first.')); diff --git a/packages/bcode-browser/test/browser-auto-connect.test.ts b/packages/bcode-browser/test/browser-auto-connect.test.ts index f798e3664..9f6fbbf20 100644 --- a/packages/bcode-browser/test/browser-auto-connect.test.ts +++ b/packages/bcode-browser/test/browser-auto-connect.test.ts @@ -276,8 +276,10 @@ test("an explicit browser switch retires the old socket and target attachment", ); }); -test("a timeout replacement does not auto-attach the run-start browser again", async () => { +test("a timeout preserves the same browser and target", async () => { connections = 0; + attachedCalls = 0; + pageCallsWithSession = 0; await withEnv( { V4_RUN_ID: "run-timeout", BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () => @@ -287,7 +289,13 @@ test("a timeout replacement does not auto-attach the run-start browser again", a impl.execute( { description: "Time out after initial V4 bootstrap", - code: "await new Promise(resolve => setTimeout(resolve, 100))", + code: ` + const navigate = session.Page.navigate + setTimeout(async () => { + try { await navigate({ url: "https://too-late.example" }) } catch {} + }, 30) + await new Promise(resolve => setTimeout(resolve, 100)) + `, timeout: 10, }, { sessionID, workspaceDir }, @@ -295,18 +303,20 @@ test("a timeout replacement does not auto-attach the run-start browser again", a ), ).rejects.toThrow("browser_execute timed out"); - await expect( - Effect.runPromise( - impl.execute( - { - description: "Do not silently return to the run-start browser", - code: "return await session.Page.navigate({ url: 'https://sap.com' })", - }, - { sessionID, workspaceDir }, - ), + const recovered = await Effect.runPromise( + impl.execute( + { + description: "Continue on the same browser", + code: "return await session.Page.navigate({ url: 'https://sap.com' })", + }, + { sessionID, workspaceDir }, ), - ).rejects.toThrow("Not connected. Call session.connect(...) first."); + ); + expect(JSON.parse(recovered.result)).toEqual({}); + await new Promise((resolve) => setTimeout(resolve, 40)); expect(connections).toBe(1); + expect(attachedCalls).toBe(1); + expect(pageCallsWithSession).toBe(1); }), ); }); diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index 5c62b65e0..11235fa62 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -225,9 +225,9 @@ test("console.debug is captured; uncommon methods fall through without throwing" }) // Timeout isolation: a timed-out snippet keeps running as an orphan (JS -// Promises are not preemptible), so the tool must retire the Session object -// the snippet received and surface captured output in the error. No Chrome -// required — the snippets sleep without touching the browser. +// Promises are not preemptible), so its scoped Session view must stop working +// while the persistent Session remains available to the next call. Browser +// behavior is covered by browser-auto-connect; these snippets need no Chrome. const runTimeout = async (id: string, code: string, timeout: number, onChunk?: (o: string) => Effect.Effect) => { const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-")) 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?: ( return err } -test("timeout returns partial output and retires the session", async () => { +test("timeout returns partial output and preserves the session", async () => { const id = "timeout-isolation-test" const before = SessionStore.get(id) const err = await runTimeout( @@ -261,12 +261,10 @@ test("timeout returns partial output and retires the session", async () => { expect(err).toContain("timed out after 100 ms") expect(err).toContain("Partial console output before timeout:") expect(err).toContain("progress-marker") - // The orphan's Session is permanently dead... + // The next call gets the exact same persistent Session. The orphan only had + // a scoped view, whose post-timeout CDP behavior is covered by the V4 test. expect(before.isConnected()).toBe(false) - await expect(before.connect({ wsUrl: "ws://127.0.0.1:9/nope" })).rejects.toThrow(/timed out after 100 ms/) - await expect(before.domains.Runtime.evaluate({ expression: "1" })).rejects.toThrow(/timed out after 100 ms/) - // ...and the next tool call gets a fresh one. - expect(SessionStore.get(id)).not.toBe(before) + expect(SessionStore.get(id)).toBe(before) await SessionStore.evict(id) }) @@ -294,7 +292,7 @@ test("re-running the execute effect after a timeout gets fresh state", async () const impl = await Effect.runPromise(BrowserExecute.make(data)) // One Effect value, run twice. Each run must resolve its own Session and // capture buffer — the second run's error must carry its own partial - // output, not inherit the first run's frozen capture or retired Session. + // output, not inherit the first run's frozen capture or scoped view. // onChunk deliveries discriminate: a run that inherited a frozen capture // buffer never tees, so it produces zero chunks (the frozen buffer still // *contains* run 1's text, which is why asserting on the error message diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index c5b916b4e..f62da1144 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -1,7 +1,7 @@ // waitFor semantics against a bare WebSocket server (no Chrome needed). // Test structure adapted from PR #111 by @MagMueller. import { afterAll, beforeAll, expect, test } from "bun:test" -import { Session } from "../src/cdp/session" +import { Session, withSessionExecution } from "../src/cdp/session" const channel = "cdp-events" const server = Bun.serve({ @@ -13,7 +13,10 @@ const server = Bun.serve({ open(ws) { ws.subscribe(channel) }, - message() {}, + message(ws, message) { + const request = JSON.parse(String(message)) as { id?: number } + if (typeof request.id === "number") ws.send(JSON.stringify({ id: request.id, result: {} })) + }, }, }) const session = new Session() @@ -78,6 +81,53 @@ test("waitFor throws on a positional timeout rather than silently using the 30s expect(Date.now() - started).toBeLessThan(1_000) }) +test("event callbacks retain the execution scope that registered them", async () => { + const execution = { active: true } + let callbackError = "callback did not run" + const unsubscribe = withSessionExecution(execution, () => + session.onEvent((method) => { + if (method !== "Test.late") return + try { + session.setActiveSession("late-session") + callbackError = "late command succeeded" + } catch (error) { + callbackError = error instanceof Error ? error.message : String(error) + } + }), + ) + + execution.active = false + emit("Test.late", {}) + await Bun.sleep(10) + unsubscribe() + + expect(callbackError).toBe("browser_execute call already timed out") + expect(session.getActiveSession()).not.toBe("late-session") +}) + +test("call-result callbacks retain the execution scope that registered them", async () => { + const execution = { active: true } + let callbackError = "callback did not run" + const registered = withSessionExecution(execution, () => ({ + unsubscribe: session.onCallResult(() => { + try { + session.setActiveSession("late-result-session") + callbackError = "late command succeeded" + } catch (error) { + callbackError = error instanceof Error ? error.message : String(error) + } + }), + result: session._call("Test.call", {}), + })) + + execution.active = false + await registered.result + registered.unsubscribe() + + expect(callbackError).toBe("browser_execute call already timed out") + expect(session.getActiveSession()).not.toBe("late-result-session") +}) + // Retirement guarantee under in-flight connects: an invalidation landing // while connect() is between awaits must not leave a usable or open socket. test("invalidate before the socket exists rejects the in-flight connect", async () => { From 2d7bb37825763030ffe8367e0f35074c8a153f72 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 13:34:08 -0700 Subject: [PATCH 2/8] docs(browser): discourage oversized execute batches --- packages/bcode-browser/skills/browser-execute/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index c1fdcf797..75f44f030 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -202,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `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. +- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Prefer the default and small batches: a longer timeout also delays your next chance to inspect or recover, so split multi-page loops into separate calls. 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. ## Console - `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. From a9a0d62cfe2998c0353a122491cf74084e5ff966 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 15:37:56 -0700 Subject: [PATCH 3/8] docs(browser): clarify target recovery after timeout --- packages/bcode-browser/skills/browser-execute/SKILL.md | 2 +- packages/bcode-browser/src/cdp/session.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 75f44f030..f08948328 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -202,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Prefer the default and small batches: a longer timeout also delays your next chance to inspect or recover, so split multi-page loops into separate calls. 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. +- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Prefer the default and small batches: a longer timeout also delays your next chance to inspect or recover, so split multi-page loops into separate calls. After a timeout, the same CDP session and active target are preserved, but an already-sent command may still be running in Chrome. Try one small state check; if that target is unresponsive, attach a fresh page target instead of retrying longer, and reconnect only if the socket actually closed. ## Console - `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. diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index dee2b3720..a74be8f1f 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -96,7 +96,6 @@ export class Session implements Transport { * and we connect directly to the supplied endpoint. */ async connect(opts: ConnectOptions = {}): Promise { - assertExecutionActive(); if (this.invalidatedError) throw this.invalidatedError; const timeoutMs = opts.timeoutMs ?? 5_000; if (opts.wsUrl || opts.profileDir) { @@ -284,7 +283,6 @@ export class Session implements Transport { opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {}, ...rest: never[] ): Promise { - assertExecutionActive(); // Both legacy positional shapes fail loudly rather than silently reverting // to the 30s default: `(method, predicate)` lands on the first guard, // `(method, predicate?, timeoutMs)` on the second. Snippets are written at From db6c01f4ac26b029ab9a94595d6866fe104c5006 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 15:42:38 -0700 Subject: [PATCH 4/8] test(browser): cover in-flight timeout recovery --- .../test/browser-auto-connect.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/bcode-browser/test/browser-auto-connect.test.ts b/packages/bcode-browser/test/browser-auto-connect.test.ts index 9f6fbbf20..9c2cfee5f 100644 --- a/packages/bcode-browser/test/browser-auto-connect.test.ts +++ b/packages/bcode-browser/test/browser-auto-connect.test.ts @@ -34,6 +34,18 @@ const server = Bun.serve({ typeof request.method !== "string" ) return; + if ( + request.method === "Page.navigate" && + "params" in request && + request.params && + typeof request.params === "object" && + "url" in request.params && + request.params.url === "https://stuck.example" + ) { + pageCallsWithSession++; + setTimeout(() => ws.send(JSON.stringify({ id: request.id, result: {} })), 40); + return; + } const result = (() => { if (request.method === "Target.getTargets") return { @@ -290,11 +302,8 @@ test("a timeout preserves the same browser and target", async () => { { description: "Time out after initial V4 bootstrap", code: ` - const navigate = session.Page.navigate - setTimeout(async () => { - try { await navigate({ url: "https://too-late.example" }) } catch {} - }, 30) - await new Promise(resolve => setTimeout(resolve, 100)) + await session.Page.navigate({ url: "https://stuck.example" }) + try { await session.Page.navigate({ url: "https://too-late.example" }) } catch {} `, timeout: 10, }, @@ -316,7 +325,7 @@ test("a timeout preserves the same browser and target", async () => { await new Promise((resolve) => setTimeout(resolve, 40)); expect(connections).toBe(1); expect(attachedCalls).toBe(1); - expect(pageCallsWithSession).toBe(1); + expect(pageCallsWithSession).toBe(2); }), ); }); From bbe4ad134044dd5d7c0b6b73986ee0624c389f07 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 15:43:17 -0700 Subject: [PATCH 5/8] test(browser): reject late waiter registration --- packages/bcode-browser/src/cdp/session.ts | 1 + packages/bcode-browser/test/cdp-session.test.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index a74be8f1f..1eceb2615 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -283,6 +283,7 @@ export class Session implements Transport { opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {}, ...rest: never[] ): Promise { + assertExecutionActive(); // Both legacy positional shapes fail loudly rather than silently reverting // to the 30s default: `(method, predicate)` lands on the first guard, // `(method, predicate?, timeoutMs)` on the second. Snippets are written at diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index f62da1144..e6250cbf2 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -81,6 +81,12 @@ test("waitFor throws on a positional timeout rather than silently using the 30s expect(Date.now() - started).toBeLessThan(1_000) }) +test("inactive executions cannot start a waiter", () => { + expect(() => + withSessionExecution({ active: false }, () => session.waitFor("Test.late", { timeoutMs: 10 })), + ).toThrow("browser_execute call already timed out") +}) + test("event callbacks retain the execution scope that registered them", async () => { const execution = { active: true } let callbackError = "callback did not run" From 37e0bb23818e45b12020b0dcb645b31ea0141750 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 16:08:09 -0700 Subject: [PATCH 6/8] docs(browser): clarify timeout probe --- packages/bcode-browser/skills/browser-execute/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index f08948328..41d9a3bcd 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -202,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Prefer the default and small batches: a longer timeout also delays your next chance to inspect or recover, so split multi-page loops into separate calls. After a timeout, the same CDP session and active target are preserved, but an already-sent command may still be running in Chrome. Try one small state check; if that target is unresponsive, attach a fresh page target instead of retrying longer, and reconnect only if the socket actually closed. +- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Keep calls small and do not increase the timeout after a page command stalls. A timeout preserves the connection and target, and the last command may still run: probe with browser-level `Target.getTargets`; if that works but page commands hang, attach a fresh page target. Reconnect only if the socket closed. ## Console - `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. From e2e5b6f18d2b4f20bf243035c5dd4972bb85aa32 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 18:50:37 -0700 Subject: [PATCH 7/8] docs(browser): leave timeout recovery to agent --- packages/bcode-browser/skills/browser-execute/SKILL.md | 4 ++-- packages/bcode-browser/src/browser-execute.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 41d9a3bcd..d320ce05f 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -110,7 +110,7 @@ If a target-scoped command throws `CdpError` code `-32001` (`Session with given 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. -New tabs and windows are separate targets with `type: "page"`; opening one does not move the current attachment, so `Page`/`Runtime` commands still go to the old tab and waiting for a page event does not switch to the new one. If the task continues in the new tab, re-list targets, identify the intended page by URL, title, or `targetId` rather than list position or an iframe/OOPIF target, then call `session.use(targetId)`. +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. ## Driving a page Domain methods follow `session..(params)` and return Promises. @@ -202,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `browser_execute` defaults to 60s (max 600s); inner CDP timeouts do not extend it. Keep calls small and do not increase the timeout after a page command stalls. A timeout preserves the connection and target, and the last command may still run: probe with browser-level `Target.getTargets`; if that works but page commands hang, attach a fresh page target. Reconnect only if the socket closed. +- `browser_execute` defaults to 60s; longer timeouts delay your next turn. A timeout returns control without closing CDP, though the last command may still run. Use browser-level `Target.getTargets` to inspect the connection, then continue, switch targets, or reconnect as appropriate. ## Console - `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. diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index b0fb5b87d..7f8375f93 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -262,7 +262,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) const output = timeoutOutput(captured.output) const error = new Error( [ - `browser_execute timed out after ${timeout} ms; the browser session remains connected`, + `browser_execute timed out after ${timeout} ms; the timeout did not close the CDP session`, output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "", ] .filter(Boolean) From bd975b15200f6c0a1683d57812f3e7db4a46a86e Mon Sep 17 00:00:00 2001 From: MagMueller Date: Sun, 2 Aug 2026 19:11:27 -0700 Subject: [PATCH 8/8] docs(browser): explain timeout state --- packages/bcode-browser/skills/browser-execute/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index d320ce05f..fc11f6738 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -202,7 +202,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. -- `browser_execute` defaults to 60s; longer timeouts delay your next turn. A timeout returns control without closing CDP, though the last command may still run. Use browser-level `Target.getTargets` to inspect the connection, then continue, switch targets, or reconnect as appropriate. +- `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. ## Console - `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.