Skip to content

fix: fail fast on tool calls stuck after a dead browser connection - #2699

Open
bitbay wants to merge 1 commit into
ChromeDevTools:mainfrom
bitbay:fix/connection-recovery-timeout
Open

fix: fail fast on tool calls stuck after a dead browser connection#2699
bitbay wants to merge 1 commit into
ChromeDevTools:mainfrom
bitbay:fix/connection-recovery-timeout

Conversation

@bitbay

@bitbay bitbay commented Sep 8, 2026

Copy link
Copy Markdown

Root cause: when the debugged browser's CDP transport dies mid-call — silently, without ever firing a close/error/disconnected event (as opposed to a clean disconnect) — the in-flight tool call just hangs forever waiting on a response that will never come. Because ToolHandler serializes every tool call behind a single shared Mutex, that one stuck call blocks all subsequent tool calls too, effectively taking the whole MCP server down until someone manually reconnects via /mcp.

Observed when an Android app being debugged over chrome-devtools-mcp was reinstalled/relaunched mid-session, the MCP server's tools went completely unavailable, even though the underlying CDP endpoint (http://127.0.0.1:9222/json) stayed reachable the whole time. This wasn't a startup/connection-time failure, which the existing "next-call self-heal" logic already handles fine — it was a hang mid-call, so there was never a clean "next call" for that self-heal to run on.

  • browser.ts: export forgetBrowser() and attach a disconnected listener after every successful connect/launch, so a browser Puppeteer does detect as closed is dropped from the module cache immediately rather than relying solely on the next call's browser?.connected check.
  • ToolHandler.ts: bound each tool handler invocation with a 60s timeout. On timeout, forget the cached browser handle and fail with a clear error instead of hanging until the client's own timeout gives up on the whole server. The next tool call then reconnects via the existing ensureBrowserConnected/#getContext self-heal path instead of reusing a handle that still looks connected. The forget call is now injectable via an optional constructor parameter (defaulting to the real forgetBrowser), so the timeout path can be unit tested without stubbing an ES module.
  • tests/ToolHandler.test.ts: cover the timeout path directly — a tool handler that never resolves triggers the 60s timeout, forgetBrowser is called with the current browser, and a normal (non-timeout) handler rejection does not trigger it.
  • tests/browser.test.ts: cover the disconnected-listener path against a real launched browser — a clean disconnect() is forgotten automatically so the next ensureBrowserConnected() reconnects instead of reusing the dead handle, and forgetBrowser() only clears the cache on an exact reference match.

Deliberately scoped to fail-fast + rely on the existing next-call self-heal, not automatic retry-and-replay of the timed-out call (a larger change to discuss with whoever verifies this against the real device). A full Android reproduction is out of scope for this change; verified here via the targeted unit tests above.

@google-cla

google-cla Bot commented Sep 8, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Root cause: when the debugged browser's CDP transport dies mid-call —
silently, without ever firing a `close`/`error`/`disconnected` event (as
opposed to a clean disconnect) — the in-flight tool call just hangs
forever waiting on a response that will never come. Because `ToolHandler`
serializes every tool call behind a single shared `Mutex`, that one stuck
call blocks all subsequent tool calls too, effectively taking the whole
MCP server down until someone manually reconnects via `/mcp`.

Observed when an Android app being debugged over `chrome-devtools-mcp`
was reinstalled/relaunched mid-session, the MCP server's tools went
completely unavailable, even though the underlying CDP endpoint
(http://127.0.0.1:9222/json) stayed reachable the whole time. This wasn't
a startup/connection-time failure, which the existing "next-call
self-heal" logic already handles fine — it was a hang mid-call, so there
was never a clean "next call" for that self-heal to run on.

- `browser.ts`: export `forgetBrowser()` and attach a `disconnected`
  listener after every successful connect/launch, so a browser Puppeteer
  does detect as closed is dropped from the module cache immediately
  rather than relying solely on the next call's `browser?.connected`
  check.
- `ToolHandler.ts`: bound each tool handler invocation with a 60s
  timeout. On timeout, forget the cached browser handle and fail with a
  clear error instead of hanging until the client's own timeout gives up
  on the whole server. The next tool call then reconnects via the
  existing `ensureBrowserConnected`/`#getContext` self-heal path instead
  of reusing a handle that still looks connected. The forget call is now
  injectable via an optional constructor parameter (defaulting to the
  real `forgetBrowser`), so the timeout path can be unit tested without
  stubbing an ES module.
- `tests/ToolHandler.test.ts`: cover the timeout path directly — a tool
  handler that never resolves triggers the 60s timeout, `forgetBrowser`
  is called with the current browser, and a normal (non-timeout) handler
  rejection does not trigger it.
- `tests/browser.test.ts`: cover the disconnected-listener path against
  a real launched browser — a clean `disconnect()` is forgotten
  automatically so the next `ensureBrowserConnected()` reconnects
  instead of reusing the dead handle, and `forgetBrowser()` only clears
  the cache on an exact reference match.

Deliberately scoped to fail-fast + rely on the existing next-call self-heal,
not automatic retry-and-replay of the timed-out call (a larger change to
discuss with whoever verifies this against the real device). A full Android
reproduction is out of scope for this change; verified here via the
targeted unit tests above.
@bitbay
bitbay force-pushed the fix/connection-recovery-timeout branch from 270c579 to 6d2d4e9 Compare September 8, 2026 15:22
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