From d94efb04f82f5da30951c473096714030aee332a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20M=C3=BCller?= <67061560+MagMueller@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:31:19 +0000 Subject: [PATCH 1/3] fix(browser): surface renderer crashes without losing browser control --- docs/renderer-crash-recovery.txt | 28 ++++++++++ .../skills/browser-execute/SKILL.md | 2 + packages/bcode-browser/src/cdp/session.ts | 42 +++++++++++++- .../test/renderer-crash-events.test.ts | 55 +++++++++++++++++++ .../bcode-browser/test/renderer-crash.test.ts | 46 ++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 docs/renderer-crash-recovery.txt create mode 100644 packages/bcode-browser/test/renderer-crash-events.test.ts create mode 100644 packages/bcode-browser/test/renderer-crash.test.ts diff --git a/docs/renderer-crash-recovery.txt b/docs/renderer-crash-recovery.txt new file mode 100644 index 000000000..60356bd47 --- /dev/null +++ b/docs/renderer-crash-recovery.txt @@ -0,0 +1,28 @@ +Renderer crash recovery: evidence and release boundary +2026-10-07 + +Defect +A browser WebSocket can remain open after its attached renderer dies. Session previously forwarded Inspector.targetCrashed to optional listeners without rejecting pending CDP requests or recording a fatal page state. Thus Runtime.evaluate, screenshots, and page-event waits could stall until the enclosing tool timeout, while Browser/Target commands still worked. Reconnecting alone is not proof of page health. + +Reproduction +Base: 5140cef334fad780b1a1a8d24fa2f56b37d7d6ef. +The Cloud-pinned v0.1.21-telemetry.1 source also lacks renderer-crash handling. +Runtime: Bun 1.3.14, Google Chrome 155.0.8059.39, dedicated loopback CDP profile on Linux. +The real-browser test creates a local page, stores a cookie and localStorage value, starts a pending Runtime.evaluate, and invokes Page.crash. On the base implementation the pending operation reaches the test deadline instead of returning RendererCrashedError. With this patch pending calls reject, further page calls and known-dead reattachment reject, Target controls stay usable, and a replacement tab can evaluate and screenshot. Both cookie and localStorage survive. +Page.crash tests the crash boundary, not the cause of memory pressure. It is not a reproduction of a customer's SAP workload or a proof of a memory leak. + +Change +session.use enables Inspector crash notifications. Errors are scoped to the CDP attachment that crashed; another attachment and browser-level controls remain usable. No command, navigation, or transaction is replayed automatically. Reconnect clears connection-scoped bookkeeping. A reload-after-crash event clears the affected session's crash state. Agent guidance explains same-browser recovery and checking whether a previous submission already committed. + +Verification +57 tests passed, one unrelated live HTTP test skipped with BCODE_SMOKE_CHROME=1, BCODE_SMOKE_PROFILE_DIR and BCODE_CRASH_PROFILE_DIR pointing to an isolated Chrome profile. Package bun typecheck passed. Event tests cover attachment isolation, no replay, wait rejection, reload and reconnect. The live crash test failed before the patch and passed after it. +Reproduce from packages/bcode-browser using bun test test/renderer-crash.test.ts with BCODE_CRASH_PROFILE_DIR set to a dedicated Chrome profile. This test intentionally crashes a page. Never point it at a user's working browser. + +Compatibility and limitations +No HTTP API, persisted schema, browser allocation, billing, or customer profile changes. One Inspector.enable round trip is added for each session.use call. Existing consumers now receive an earlier typed error after an observed crash. Raw Target.attachToTarget/setActiveSession users must enable Inspector themselves. A crash before this connection observed it, a wedged renderer without a crash event, or a dead browser process still needs a separate bounded health diagnosis. Recovery preserves browser-profile storage when the process survives; it cannot restore DOM, JS heap, sessionStorage in a closed tab, or unsaved form state. + +Rollout +This source PR alone does not change Cloud's pinned binary. Publish an immutable release, update the Cloud version/checksum deliberately, then run a staging agent task that observes a renderer crash and recovers in the same browser without replaying a side-effecting operation. Verify the agent sees the error and the preview follows the replacement tab. Run a normal navigation/screenshot task and a browser-switch task as compatibility canaries. Roll back the binary pin if normal attachment starts timing out or Inspector.enable fails on supported endpoints. No merge, staging deployment, or production rollout is claimed here. + +Historical guardrail +The 2025-11-19 Cloud cleanup/scheduler incident accumulated sessions and exhausted capacity. Avoid enlarging that failure domain: this change allocates no additional browser VM, increases no guest memory limit, and performs no automatic retry loop. Close only the known dead target during explicit recovery and preserve cancellation. The next health/telemetry work should distinguish browser liveness, page responsiveness, crash events, and guest memory pressure rather than change the meaning of the existing lifecycle status. diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index f7dd31d13..bc62afdb7 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -108,6 +108,8 @@ await session.use(page.targetId) If a target-scoped command throws `CdpError` code `-32001` (`Session with given id not found`), the browser connection is still usable but the target session is stale. List targets again, `session.use(...)` the intended page, and retry the rejected command once. Calling `session.connect()` without arguments is a no-op while connected; it does not replace a stale target session. +A `RendererCrashedError` means the attached page renderer died, even if the browser socket is still connected. Pending page commands fail immediately. Do not keep evaluating, taking screenshots, or waiting on that dead page. Use browser-level `Target.closeTarget` and `Target.createTarget`, then `session.use(newTargetId)` to recover within the same browser. Cookies and durable site storage may survive, but DOM nodes, JavaScript objects, and unsaved page state do not. Verify whether the previous business action committed before retrying it. Replace the whole browser only if browser-level controls also fail. + 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. 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. diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 1eceb2615..8c13528d5 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -10,6 +10,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { bindDomains, type Domains, type Transport } from './generated.ts'; type Pending = { + sessionId?: string; resolve: (v: unknown) => void; reject: (e: unknown) => void; }; @@ -63,6 +64,8 @@ export class Session implements Transport { private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; + private sessionTargets = new Map(); + private crashedSessions = new Map(); private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = []; private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = []; @@ -143,6 +146,8 @@ export class Session implements Transport { const previousWs = this.ws; this.ws = ws; this.activeSessionId = undefined; + this.crashedSessions.clear(); + this.sessionTargets.clear(); if (previousWs) { for (const [, p] of this.pending) p.reject(new Error('CDP connection replaced')); this.pending.clear(); @@ -214,9 +219,14 @@ export class Session implements Transport { * Uses Target.attachToTarget with flatten:true (single-WS, sessionId-on-message). */ async use(targetId: string): Promise { + for (const [sessionId, error] of this.crashedSessions) { + if (this.sessionTargets.get(sessionId) === targetId) throw error; + } const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string }; assertExecutionActive(); this.activeSessionId = r.sessionId; + this.sessionTargets.set(r.sessionId, targetId); + await this._call('Inspector.enable'); return r.sessionId; } @@ -294,12 +304,21 @@ export class Session implements Transport { if (rest.length > 0) { throw new TypeError('waitFor(method, { predicate, timeoutMs }) — pass the timeout in the options object'); } + const sessionId = isBrowserLevel(method) ? undefined : this.activeSessionId; + const crashed = sessionId && this.crashedSessions.get(sessionId); + if (crashed) return Promise.reject(crashed); const p = new Promise((resolve, reject) => { const timer = setTimeout(() => { unsub(); reject(new Error(`Timeout waiting for ${method}`)); }, opts.timeoutMs ?? 30_000); - const unsub = this.onEvent((m, params) => { + const unsub = this.onEvent((m, params, eventSessionId) => { + if (sessionId && m === 'Inspector.targetCrashed' && eventSessionId === sessionId && m !== method) { + clearTimeout(timer); + unsub(); + reject(this.crashedSessions.get(sessionId)); + return; + } if (m !== method) return; try { if (opts.predicate && !opts.predicate(params as T)) return; @@ -328,6 +347,9 @@ export class Session implements Transport { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error('Not connected. Call session.connect(...) first.')); } + const sessionId = isBrowserLevel(method) ? undefined : this.activeSessionId; + const crashed = sessionId && this.crashedSessions.get(sessionId); + if (crashed) return Promise.reject(crashed); const id = this.nextId++; const msg: Record = { id, method, params: params ?? {} }; if (this.activeSessionId && !isBrowserLevel(method)) { @@ -335,6 +357,7 @@ export class Session implements Transport { } return new Promise((resolve, reject) => { this.pending.set(id, { + sessionId, resolve: (v) => { for (const fn of this.callResultListeners) { try { fn(method, params, v); } catch { /* ignore */ } @@ -357,6 +380,16 @@ export class Session implements Transport { if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data)); else p.resolve(m.result); } else if (m.method) { + if (m.method === 'Inspector.targetCrashed' && typeof m.sessionId === 'string') { + const error = new RendererCrashedError(m.sessionId); + this.crashedSessions.set(m.sessionId, error); + for (const [id, pending] of this.pending) { + if (pending.sessionId !== m.sessionId) continue; + this.pending.delete(id); + pending.reject(error); + } + } + if (m.method === 'Inspector.targetReloadedAfterCrash') this.crashedSessions.delete(m.sessionId); for (const fn of this.eventListeners) { try { fn(m.method, m.params, m.sessionId); } catch { /* ignore */ } } @@ -364,6 +397,13 @@ export class Session implements Transport { } } +export class RendererCrashedError extends Error { + constructor(public sessionId: string) { + super('Renderer crashed. The browser may still be connected. Use Target.closeTarget and Target.createTarget, then session.use() to recover in the same browser. Page state is lost; verify any previous submission before retrying it.'); + this.name = 'RendererCrashedError'; + } +} + export class CdpError extends Error { constructor(public code: number, message: string, public data?: unknown) { super(`CDP ${code}: ${message}`); diff --git a/packages/bcode-browser/test/renderer-crash-events.test.ts b/packages/bcode-browser/test/renderer-crash-events.test.ts new file mode 100644 index 000000000..cf228eacb --- /dev/null +++ b/packages/bcode-browser/test/renderer-crash-events.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { Session } from "../src/cdp/session" + +test("crash is attachment-scoped, does not replay, and resets on reconnect", async () => { + const requests: { id: number; method: string; sessionId?: string }[] = [] + let emit = (_event: object) => {} + const server = Bun.serve({ + port: 0, + fetch: (req, srv) => srv.upgrade(req) ? undefined : new Response(null, { status: 400 }), + websocket: { + open(ws) { emit = event => { ws.send(JSON.stringify(event)) } }, + message(ws, raw) { + const request = JSON.parse(String(raw)) + requests.push(request) + if (request.method === "Runtime.evaluate") return + ws.send(JSON.stringify({ id: request.id, result: {} })) + }, + }, + }) + const session = new Session() + try { + await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}` }) + session.setActiveSession("dead") + const dead = session._call("Runtime.evaluate").catch(error => error) + const waiting = session.waitFor("Page.loadEventFired", { timeoutMs: 1000 }).catch(error => error) + session.setActiveSession("healthy") + const healthy = session._call("Runtime.evaluate") + await Bun.sleep(10) + emit({ method: "Inspector.targetCrashed", sessionId: "dead", params: {} }) + expect(await dead).toMatchObject({ name: "RendererCrashedError" }) + expect(await waiting).toMatchObject({ name: "RendererCrashedError" }) + const request = requests.find(x => x.sessionId === "healthy")! + emit({ id: request.id, result: { healthy: true } }) + expect(await healthy).toEqual({ healthy: true }) + session.setActiveSession("dead") + await expect(session._call("Runtime.evaluate")).rejects.toThrow("Renderer crashed") + await expect(session.waitFor("Page.loadEventFired")).rejects.toThrow("Renderer crashed") + await session._call("Target.getTargets") + expect(requests.filter(x => x.method === "Runtime.evaluate")).toHaveLength(2) + const reload = session.waitFor("Inspector.targetReloadedAfterCrash").catch(error => error) + // Already-crashed waits reject; the event still clears the attachment's failure. + expect(await reload).toMatchObject({ name: "RendererCrashedError" }) + emit({ method: "Inspector.targetReloadedAfterCrash", sessionId: "dead", params: {} }) + await Bun.sleep(10) + await session._call("Page.enable") + emit({ method: "Inspector.targetCrashed", sessionId: "dead", params: {} }) + await Bun.sleep(10) + await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}` }) + session.setActiveSession("dead") + await session._call("Page.enable") + } finally { + session.close() + server.stop(true) + } +}) diff --git a/packages/bcode-browser/test/renderer-crash.test.ts b/packages/bcode-browser/test/renderer-crash.test.ts new file mode 100644 index 000000000..73d35066c --- /dev/null +++ b/packages/bcode-browser/test/renderer-crash.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { Session } from "../src/cdp/session" + +const profileDir = process.env.BCODE_CRASH_PROFILE_DIR + +test.skipIf(!profileDir)("renderer crash rejects pending work and permits same-browser recovery", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("recovery", { headers: { "Content-Type": "text/html" } }) }) + const session = new Session() + const targets: string[] = [] + const bounded = async (promise: Promise) => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("TEST_DEADLINE")), 2000) }), + ]) + } finally { + clearTimeout(timer) + } + } + try { + await session.connect({ profileDir }) + const page = await session.domains.Target.createTarget({ url: `http://127.0.0.1:${server.port}/` }) + targets.push(page.targetId) + await session.use(page.targetId) + await session.domains.Runtime.evaluate({ expression: "document.cookie = 'recovery=preserved; path=/'; localStorage.setItem('saved', 'yes')" }) + const pending = bounded(session.domains.Runtime.evaluate({ expression: "new Promise(() => {})", awaitPromise: true })).catch(error => error) + const crashed = bounded(session.domains.Page.crash()).catch(error => error) + expect((await pending).name).toBe("RendererCrashedError") + expect((await crashed).name).toBe("RendererCrashedError") + expect(session.isConnected()).toBe(true) + await expect(bounded(session.use(page.targetId))).rejects.toThrow("Renderer crashed") + await expect(bounded(session.domains.Runtime.evaluate({ expression: "1" }))).rejects.toThrow("Renderer crashed") + await session.domains.Target.closeTarget({ targetId: page.targetId }) + const replacement = await session.domains.Target.createTarget({ url: `http://127.0.0.1:${server.port}/` }) + targets.push(replacement.targetId) + await session.use(replacement.targetId) + const state = await session.domains.Runtime.evaluate({ expression: "JSON.stringify({cookie: document.cookie, saved: localStorage.getItem('saved')})", returnByValue: true }) + expect(JSON.parse(String(state.result.value))).toEqual({ cookie: "recovery=preserved", saved: "yes" }) + expect((await session.domains.Page.captureScreenshot()).data.length).toBeGreaterThan(0) + } finally { + for (const targetId of targets) await session.domains.Target.closeTarget({ targetId }).catch(() => {}) + session.close() + server.stop(true) + } +}, 15000) From 6efd96da01f187a78e3e86c80daa109373c028a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20M=C3=BCller?= <67061560+MagMueller@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:45:51 +0000 Subject: [PATCH 2/3] refactor(browser): keep crash guidance minimal --- docs/renderer-crash-recovery.txt | 28 ------------------- .../skills/browser-execute/SKILL.md | 2 +- packages/bcode-browser/src/cdp/session.ts | 2 +- 3 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 docs/renderer-crash-recovery.txt diff --git a/docs/renderer-crash-recovery.txt b/docs/renderer-crash-recovery.txt deleted file mode 100644 index 60356bd47..000000000 --- a/docs/renderer-crash-recovery.txt +++ /dev/null @@ -1,28 +0,0 @@ -Renderer crash recovery: evidence and release boundary -2026-10-07 - -Defect -A browser WebSocket can remain open after its attached renderer dies. Session previously forwarded Inspector.targetCrashed to optional listeners without rejecting pending CDP requests or recording a fatal page state. Thus Runtime.evaluate, screenshots, and page-event waits could stall until the enclosing tool timeout, while Browser/Target commands still worked. Reconnecting alone is not proof of page health. - -Reproduction -Base: 5140cef334fad780b1a1a8d24fa2f56b37d7d6ef. -The Cloud-pinned v0.1.21-telemetry.1 source also lacks renderer-crash handling. -Runtime: Bun 1.3.14, Google Chrome 155.0.8059.39, dedicated loopback CDP profile on Linux. -The real-browser test creates a local page, stores a cookie and localStorage value, starts a pending Runtime.evaluate, and invokes Page.crash. On the base implementation the pending operation reaches the test deadline instead of returning RendererCrashedError. With this patch pending calls reject, further page calls and known-dead reattachment reject, Target controls stay usable, and a replacement tab can evaluate and screenshot. Both cookie and localStorage survive. -Page.crash tests the crash boundary, not the cause of memory pressure. It is not a reproduction of a customer's SAP workload or a proof of a memory leak. - -Change -session.use enables Inspector crash notifications. Errors are scoped to the CDP attachment that crashed; another attachment and browser-level controls remain usable. No command, navigation, or transaction is replayed automatically. Reconnect clears connection-scoped bookkeeping. A reload-after-crash event clears the affected session's crash state. Agent guidance explains same-browser recovery and checking whether a previous submission already committed. - -Verification -57 tests passed, one unrelated live HTTP test skipped with BCODE_SMOKE_CHROME=1, BCODE_SMOKE_PROFILE_DIR and BCODE_CRASH_PROFILE_DIR pointing to an isolated Chrome profile. Package bun typecheck passed. Event tests cover attachment isolation, no replay, wait rejection, reload and reconnect. The live crash test failed before the patch and passed after it. -Reproduce from packages/bcode-browser using bun test test/renderer-crash.test.ts with BCODE_CRASH_PROFILE_DIR set to a dedicated Chrome profile. This test intentionally crashes a page. Never point it at a user's working browser. - -Compatibility and limitations -No HTTP API, persisted schema, browser allocation, billing, or customer profile changes. One Inspector.enable round trip is added for each session.use call. Existing consumers now receive an earlier typed error after an observed crash. Raw Target.attachToTarget/setActiveSession users must enable Inspector themselves. A crash before this connection observed it, a wedged renderer without a crash event, or a dead browser process still needs a separate bounded health diagnosis. Recovery preserves browser-profile storage when the process survives; it cannot restore DOM, JS heap, sessionStorage in a closed tab, or unsaved form state. - -Rollout -This source PR alone does not change Cloud's pinned binary. Publish an immutable release, update the Cloud version/checksum deliberately, then run a staging agent task that observes a renderer crash and recovers in the same browser without replaying a side-effecting operation. Verify the agent sees the error and the preview follows the replacement tab. Run a normal navigation/screenshot task and a browser-switch task as compatibility canaries. Roll back the binary pin if normal attachment starts timing out or Inspector.enable fails on supported endpoints. No merge, staging deployment, or production rollout is claimed here. - -Historical guardrail -The 2025-11-19 Cloud cleanup/scheduler incident accumulated sessions and exhausted capacity. Avoid enlarging that failure domain: this change allocates no additional browser VM, increases no guest memory limit, and performs no automatic retry loop. Close only the known dead target during explicit recovery and preserve cancellation. The next health/telemetry work should distinguish browser liveness, page responsiveness, crash events, and guest memory pressure rather than change the meaning of the existing lifecycle status. diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index bc62afdb7..16e078595 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -108,7 +108,7 @@ await session.use(page.targetId) If a target-scoped command throws `CdpError` code `-32001` (`Session with given id not found`), the browser connection is still usable but the target session is stale. List targets again, `session.use(...)` the intended page, and retry the rejected command once. Calling `session.connect()` without arguments is a no-op while connected; it does not replace a stale target session. -A `RendererCrashedError` means the attached page renderer died, even if the browser socket is still connected. Pending page commands fail immediately. Do not keep evaluating, taking screenshots, or waiting on that dead page. Use browser-level `Target.closeTarget` and `Target.createTarget`, then `session.use(newTargetId)` to recover within the same browser. Cookies and durable site storage may survive, but DOM nodes, JavaScript objects, and unsaved page state do not. Verify whether the previous business action committed before retrying it. Replace the whole browser only if browser-level controls also fail. +On `RendererCrashedError`, close the dead target, create a new tab, and `session.use()` it; verify any previous submission before retrying. 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. diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 8c13528d5..5717dd116 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -399,7 +399,7 @@ export class Session implements Transport { export class RendererCrashedError extends Error { constructor(public sessionId: string) { - super('Renderer crashed. The browser may still be connected. Use Target.closeTarget and Target.createTarget, then session.use() to recover in the same browser. Page state is lost; verify any previous submission before retrying it.'); + super('Renderer crashed. Use Target.closeTarget, Target.createTarget, then session.use(). Verify previous submissions before retrying.'); this.name = 'RendererCrashedError'; } } From 42f8a093809a69fd20cc8561279fd497d37db001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20M=C3=BCller?= <67061560+MagMueller@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:50:29 +0000 Subject: [PATCH 3/3] docs(browser): limit crash recovery change to one sentence --- .../skills/browser-execute/SKILL.md | 2 +- packages/bcode-browser/src/cdp/session.ts | 42 +------------- .../test/renderer-crash-events.test.ts | 55 ------------------- .../bcode-browser/test/renderer-crash.test.ts | 46 ---------------- 4 files changed, 2 insertions(+), 143 deletions(-) delete mode 100644 packages/bcode-browser/test/renderer-crash-events.test.ts delete mode 100644 packages/bcode-browser/test/renderer-crash.test.ts diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 16e078595..d175e055e 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -108,7 +108,7 @@ await session.use(page.targetId) If a target-scoped command throws `CdpError` code `-32001` (`Session with given id not found`), the browser connection is still usable but the target session is stale. List targets again, `session.use(...)` the intended page, and retry the rejected command once. Calling `session.connect()` without arguments is a no-op while connected; it does not replace a stale target session. -On `RendererCrashedError`, close the dead target, create a new tab, and `session.use()` it; verify any previous submission before retrying. +If a tab or Chrome crashes, recover with a fresh tab or the available browser-replacement API instead of repeatedly retrying the dead page, and verify previous submissions before repeating them. 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. diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 5717dd116..1eceb2615 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -10,7 +10,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { bindDomains, type Domains, type Transport } from './generated.ts'; type Pending = { - sessionId?: string; resolve: (v: unknown) => void; reject: (e: unknown) => void; }; @@ -64,8 +63,6 @@ export class Session implements Transport { private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; - private sessionTargets = new Map(); - private crashedSessions = new Map(); private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = []; private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = []; @@ -146,8 +143,6 @@ export class Session implements Transport { const previousWs = this.ws; this.ws = ws; this.activeSessionId = undefined; - this.crashedSessions.clear(); - this.sessionTargets.clear(); if (previousWs) { for (const [, p] of this.pending) p.reject(new Error('CDP connection replaced')); this.pending.clear(); @@ -219,14 +214,9 @@ export class Session implements Transport { * Uses Target.attachToTarget with flatten:true (single-WS, sessionId-on-message). */ async use(targetId: string): Promise { - for (const [sessionId, error] of this.crashedSessions) { - if (this.sessionTargets.get(sessionId) === targetId) throw error; - } const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string }; assertExecutionActive(); this.activeSessionId = r.sessionId; - this.sessionTargets.set(r.sessionId, targetId); - await this._call('Inspector.enable'); return r.sessionId; } @@ -304,21 +294,12 @@ export class Session implements Transport { if (rest.length > 0) { throw new TypeError('waitFor(method, { predicate, timeoutMs }) — pass the timeout in the options object'); } - const sessionId = isBrowserLevel(method) ? undefined : this.activeSessionId; - const crashed = sessionId && this.crashedSessions.get(sessionId); - if (crashed) return Promise.reject(crashed); const p = new Promise((resolve, reject) => { const timer = setTimeout(() => { unsub(); reject(new Error(`Timeout waiting for ${method}`)); }, opts.timeoutMs ?? 30_000); - const unsub = this.onEvent((m, params, eventSessionId) => { - if (sessionId && m === 'Inspector.targetCrashed' && eventSessionId === sessionId && m !== method) { - clearTimeout(timer); - unsub(); - reject(this.crashedSessions.get(sessionId)); - return; - } + const unsub = this.onEvent((m, params) => { if (m !== method) return; try { if (opts.predicate && !opts.predicate(params as T)) return; @@ -347,9 +328,6 @@ export class Session implements Transport { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error('Not connected. Call session.connect(...) first.')); } - const sessionId = isBrowserLevel(method) ? undefined : this.activeSessionId; - const crashed = sessionId && this.crashedSessions.get(sessionId); - if (crashed) return Promise.reject(crashed); const id = this.nextId++; const msg: Record = { id, method, params: params ?? {} }; if (this.activeSessionId && !isBrowserLevel(method)) { @@ -357,7 +335,6 @@ export class Session implements Transport { } return new Promise((resolve, reject) => { this.pending.set(id, { - sessionId, resolve: (v) => { for (const fn of this.callResultListeners) { try { fn(method, params, v); } catch { /* ignore */ } @@ -380,16 +357,6 @@ export class Session implements Transport { if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data)); else p.resolve(m.result); } else if (m.method) { - if (m.method === 'Inspector.targetCrashed' && typeof m.sessionId === 'string') { - const error = new RendererCrashedError(m.sessionId); - this.crashedSessions.set(m.sessionId, error); - for (const [id, pending] of this.pending) { - if (pending.sessionId !== m.sessionId) continue; - this.pending.delete(id); - pending.reject(error); - } - } - if (m.method === 'Inspector.targetReloadedAfterCrash') this.crashedSessions.delete(m.sessionId); for (const fn of this.eventListeners) { try { fn(m.method, m.params, m.sessionId); } catch { /* ignore */ } } @@ -397,13 +364,6 @@ export class Session implements Transport { } } -export class RendererCrashedError extends Error { - constructor(public sessionId: string) { - super('Renderer crashed. Use Target.closeTarget, Target.createTarget, then session.use(). Verify previous submissions before retrying.'); - this.name = 'RendererCrashedError'; - } -} - export class CdpError extends Error { constructor(public code: number, message: string, public data?: unknown) { super(`CDP ${code}: ${message}`); diff --git a/packages/bcode-browser/test/renderer-crash-events.test.ts b/packages/bcode-browser/test/renderer-crash-events.test.ts deleted file mode 100644 index cf228eacb..000000000 --- a/packages/bcode-browser/test/renderer-crash-events.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "bun:test" -import { Session } from "../src/cdp/session" - -test("crash is attachment-scoped, does not replay, and resets on reconnect", async () => { - const requests: { id: number; method: string; sessionId?: string }[] = [] - let emit = (_event: object) => {} - const server = Bun.serve({ - port: 0, - fetch: (req, srv) => srv.upgrade(req) ? undefined : new Response(null, { status: 400 }), - websocket: { - open(ws) { emit = event => { ws.send(JSON.stringify(event)) } }, - message(ws, raw) { - const request = JSON.parse(String(raw)) - requests.push(request) - if (request.method === "Runtime.evaluate") return - ws.send(JSON.stringify({ id: request.id, result: {} })) - }, - }, - }) - const session = new Session() - try { - await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}` }) - session.setActiveSession("dead") - const dead = session._call("Runtime.evaluate").catch(error => error) - const waiting = session.waitFor("Page.loadEventFired", { timeoutMs: 1000 }).catch(error => error) - session.setActiveSession("healthy") - const healthy = session._call("Runtime.evaluate") - await Bun.sleep(10) - emit({ method: "Inspector.targetCrashed", sessionId: "dead", params: {} }) - expect(await dead).toMatchObject({ name: "RendererCrashedError" }) - expect(await waiting).toMatchObject({ name: "RendererCrashedError" }) - const request = requests.find(x => x.sessionId === "healthy")! - emit({ id: request.id, result: { healthy: true } }) - expect(await healthy).toEqual({ healthy: true }) - session.setActiveSession("dead") - await expect(session._call("Runtime.evaluate")).rejects.toThrow("Renderer crashed") - await expect(session.waitFor("Page.loadEventFired")).rejects.toThrow("Renderer crashed") - await session._call("Target.getTargets") - expect(requests.filter(x => x.method === "Runtime.evaluate")).toHaveLength(2) - const reload = session.waitFor("Inspector.targetReloadedAfterCrash").catch(error => error) - // Already-crashed waits reject; the event still clears the attachment's failure. - expect(await reload).toMatchObject({ name: "RendererCrashedError" }) - emit({ method: "Inspector.targetReloadedAfterCrash", sessionId: "dead", params: {} }) - await Bun.sleep(10) - await session._call("Page.enable") - emit({ method: "Inspector.targetCrashed", sessionId: "dead", params: {} }) - await Bun.sleep(10) - await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}` }) - session.setActiveSession("dead") - await session._call("Page.enable") - } finally { - session.close() - server.stop(true) - } -}) diff --git a/packages/bcode-browser/test/renderer-crash.test.ts b/packages/bcode-browser/test/renderer-crash.test.ts deleted file mode 100644 index 73d35066c..000000000 --- a/packages/bcode-browser/test/renderer-crash.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from "bun:test" -import { Session } from "../src/cdp/session" - -const profileDir = process.env.BCODE_CRASH_PROFILE_DIR - -test.skipIf(!profileDir)("renderer crash rejects pending work and permits same-browser recovery", async () => { - const server = Bun.serve({ port: 0, fetch: () => new Response("recovery", { headers: { "Content-Type": "text/html" } }) }) - const session = new Session() - const targets: string[] = [] - const bounded = async (promise: Promise) => { - let timer: ReturnType | undefined - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("TEST_DEADLINE")), 2000) }), - ]) - } finally { - clearTimeout(timer) - } - } - try { - await session.connect({ profileDir }) - const page = await session.domains.Target.createTarget({ url: `http://127.0.0.1:${server.port}/` }) - targets.push(page.targetId) - await session.use(page.targetId) - await session.domains.Runtime.evaluate({ expression: "document.cookie = 'recovery=preserved; path=/'; localStorage.setItem('saved', 'yes')" }) - const pending = bounded(session.domains.Runtime.evaluate({ expression: "new Promise(() => {})", awaitPromise: true })).catch(error => error) - const crashed = bounded(session.domains.Page.crash()).catch(error => error) - expect((await pending).name).toBe("RendererCrashedError") - expect((await crashed).name).toBe("RendererCrashedError") - expect(session.isConnected()).toBe(true) - await expect(bounded(session.use(page.targetId))).rejects.toThrow("Renderer crashed") - await expect(bounded(session.domains.Runtime.evaluate({ expression: "1" }))).rejects.toThrow("Renderer crashed") - await session.domains.Target.closeTarget({ targetId: page.targetId }) - const replacement = await session.domains.Target.createTarget({ url: `http://127.0.0.1:${server.port}/` }) - targets.push(replacement.targetId) - await session.use(replacement.targetId) - const state = await session.domains.Runtime.evaluate({ expression: "JSON.stringify({cookie: document.cookie, saved: localStorage.getItem('saved')})", returnByValue: true }) - expect(JSON.parse(String(state.result.value))).toEqual({ cookie: "recovery=preserved", saved: "yes" }) - expect((await session.domains.Page.captureScreenshot()).data.length).toBeGreaterThan(0) - } finally { - for (const targetId of targets) await session.domains.Target.closeTarget({ targetId }).catch(() => {}) - session.close() - server.stop(true) - } -}, 15000)