Skip to content

fix(agents): retry connect() when the browser expires mid-connect - #2368

Open
ben-reitz wants to merge 1 commit into
refactor/browser-classfrom
fix/browser-connect-expiry-race
Open

ben-reitz wants to merge 1 commit into
refactor/browser-classfrom
fix/browser-connect-expiry-race

Conversation

@ben-reitz

@ben-reitz ben-reitz commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: fixes a race in connecting to the browser.

Browser.connect() gives the host a working browser. It does two steps:

  1. resolve() checks whether the browser is still alive, and creates a replacement if it isn't.
  2. connect() then opens a CDP WebSocket to the resolved browser.

The persistent browser tool later in this stack uses it.

connect() could throw if a browser expired after resolve()'s liveness probe but before the WebSocket upgrade. It now replaces the browser, the same way resolve() does. This fixes the Devin thread on #2302.

connect()
  resolved = resolve()                  // probe: alive
  attach(resolved)
    upgrade → 404 / 410                 // expired in between
      retire the record if it still holds resolved.sessionId
      attach(resolve())                 → restarted: true
    any other failure                   → throw
  • Retries once. A second failure throws.
  • Safe under races. The record is retired under the browser's lock, and only if it still holds the dead browser. A caller that already replaced the browser keeps its replacement, and both callers report restarted: true.
  • Public error change (patch changeset). When Browser Run returns no WebSocket, connectBrowserSession now throws BrowserRenderingError, with the HTTP status in the message, instead of a plain Error. It still extends Error, so existing catch blocks keep working.

Tests in browser.test.ts: a 410 on upgrade returns a replacement; a 502 is not retried.

Architecture diff

Each outlined box is a component this PR changes, with one green box per change. Grey boxes are unchanged.

flowchart TB
  classDef ctx fill:none,stroke:#8c959f,color:#8c959f
  classDef changes fill:#dafbe1,stroke:#1a7f37,color:#1f2328

  Agent["Agent (host Durable Object)"]:::ctx

  subgraph B["Browser · browser.ts"]
    direction TB
    N1["+ connect() retries once on a 404/410 upgrade"]:::changes
    N2["+ retires the dead record only if it's still current"]:::changes
    N1 ~~~ N2
  end

  subgraph Run["connectBrowserSession · browser-run.ts"]
    R1["+ throws BrowserRenderingError with the upgrade status"]:::changes
  end

  Store["StoredBrowserSession · session-store.ts"]:::ctx
  BR["Browser Run"]:::ctx

  Agent -->|lifecycle.use| B
  B -->|"one record"| Store
  B -->|"open CDP socket"| Run
  Run -->|"WebSocket upgrade"| BR

  style B stroke:#1a7f37,stroke-width:2px
  style Run stroke:#1a7f37,stroke-width:2px
Loading

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2eb5d4a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ben-reitz
ben-reitz added this pull request to stack #2373 September 25, 2026 08:21
@pkg-pr-new

pkg-pr-new Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2368

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2368

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2368

hono-agents

npm i https://pkg.pr.new/hono-agents@2368

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2368

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2368

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2368

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2368

commit: 2eb5d4a

@ben-reitz
ben-reitz marked this pull request as ready for review September 25, 2026 08:49
devin-ai-integration[bot]

This comment was marked as resolved.

@ben-reitz
ben-reitz force-pushed the fix/browser-connect-expiry-race branch from 5fefaea to acda0cb Compare September 25, 2026 09:13
@cloudflare cloudflare deleted a comment from agent-think Bot Sep 25, 2026
@ben-reitz
ben-reitz force-pushed the fix/browser-connect-expiry-race branch 2 times, most recently from acda0cb to 5fefaea Compare September 25, 2026 11:27
@agent-think

agent-think Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ agents import sizes: no significant changes (e8379676 → e339ad85, workflow run)

@ben-reitz
ben-reitz force-pushed the fix/browser-connect-expiry-race branch 8 times, most recently from 213b48c to e339ad8 Compare September 28, 2026 12:29
@ben-reitz
ben-reitz removed this pull request from stack #2373 September 29, 2026 13:34
@ben-reitz
ben-reitz force-pushed the fix/browser-connect-expiry-race branch from e339ad8 to 2eb5d4a Compare September 29, 2026 13:35
@ben-reitz
ben-reitz changed the base branch from main to refactor/browser-class September 29, 2026 13:36
@ben-reitz
ben-reitz added this pull request to stack #2412 September 29, 2026 13:36

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines 257 to +269
async connect(): Promise<BrowserConnection> {
const resolved = await this.resolve();
try {
return await this.#attach(resolved);
} catch (error) {
if (!isMissingBrowserSession(error)) throw error;
await this.#retireIfCurrent(resolved.sessionId);
}
// The browser this caller resolved is gone, so report a restart even
// when a concurrent resolver already replaced it and this resolve
// reattaches to that replacement.
const replaced = await this.resolve();
return this.#attach({ ...replaced, restarted: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Connector remains outside the retry

The retry applies to Browser.connect(), but BrowserConnector calls connectBrowserSession() directly. Its probe-to-upgrade race remains, despite the PR description naming the connector as a beneficiary.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant