Skip to content

Stop browser evals from hanging a tool call forever - #299

Merged
frenchie4111 merged 2 commits into
mainfrom
fix/browser-eval-hang
Aug 26, 2026
Merged

Stop browser evals from hanging a tool call forever#299
frenchie4111 merged 2 commits into
mainfrom
fix/browser-eval-hang

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

Summary

get_tab_dom could sit unanswered for ten minutes and never return or error — the user had to interrupt it.

executeJavaScript on a WebContentsView with no live renderer — one whose main frame never committed, or whose renderer process crashed — neither resolves nor rejects. It queues for a frame that never arrives, so the existing try/catch could not rescue the caller (confirmed by the missing getDom failed log line). Three layers deep, nothing had a deadline: BrowserManager.getDom, the /browser/dom control-server handler, and callControl in the MCP bridge (Node's http client has no default request timeout).

getClickables, scrollTab, and showCursor had the identical flaw — all four are awaited behind control-server handlers.

The trigger in the reported incident was a renderer crash, not a dead URL: the page loaded and ran script, then SpeechRecognition.available({processLocally: true}) killed the renderer process, which Electron surfaces as a late ERR_FAILED (-2). A getURL()-based precondition would not have caught it, so detection is crash-aware.

Changes

  • src/main/browser-eval.ts (new, runtime-agnostic, shared by both backends):
    • evalWithTimeout races the eval against a 5s timer, clearing it in a finally.
    • evalBlockedReason refuses upfront with an actionable message — crash first (tab renderer crashed (reason: crashed) — reload the tab), then never-committed-document. A tab merely mid-first-load returns null, so "create tab, immediately read DOM" still queues as it does today.
  • BrowserManager.evalInTab routes all four eval callers through the helper. Crash state comes from a new render-process-gone listener plus a live isCrashed() check; a new did-fail-load listener records the load error (there wasn't one before — the loadURL failed log came from the loadURL promise's .catch); dom-ready clears both.
  • Errors propagate instead of collapsing to null, so the control server's existing top-level catch returns a 500 carrying the real reason rather than a generic dom read failed.
  • Playwright backend gets the timeout wrap for parity. Its pages always have a committed about:blank, so the frameless hang can't occur there — noted in a comment.
  • callControl gets a 60s socket timeout with req.destroy(err) (the timeout event alone doesn't abort) as a backstop, so no future endpoint can wedge a tool call indefinitely. POST /worktrees gets 300s since it legitimately blocks on git fetch / PR checkout.

Second commit: cap get_tab_dom output

It was the only browser tool with no output bound — console logs cap at 200 entries, clickables at 500 items, screenshots default to JPEG q70, but outerHTML came back verbatim. A heavy page is 1–5MB of markup in one tool result. Now truncated to 100KB by default with a notice naming the real size and a max_bytes override.

Test plan

  • npm run typecheck
  • npx electron-vite build
  • npx vitest run — 5 failures, all pre-existing flakes in git-ops-state / path-fix / worktree-watcher.integration under suite contention; verified identical on a stashed clean tree
  • browser-eval.test.ts — never-settles eval rejects on a timer, timer cleared on the success path, each blocked-reason branch including crash-beats-ERR_FAILED precedence
  • control-server.test.ts — crash / timeout / dead-URL each come back as a 500 with the message intact
  • mcp-bridge.test.js — end-to-end regression test: a stub server that never responds now errors instead of hanging; plus DOM truncation cases
  • Not verified end-to-end against a live crashing tab. The MCP browser tools drive the running Ness, which is on unfixed code — pointing them at the repro page would hang a tool call for the full timeout. Worth a manual pass with a dev build from this branch against a page calling SpeechRecognition.available({langs:['en-US'], processLocally:true}).

🤖 Generated with Claude Code

frenchie4111 and others added 2 commits August 21, 2026 20:00
get_tab_dom could sit unanswered for minutes. executeJavaScript on a
WebContentsView with no live renderer — one whose main frame never
committed, or whose renderer process crashed — neither resolves nor
rejects, so the existing try/catch could not rescue the caller, and
neither the control-server handler nor the MCP bridge had a deadline of
its own.

Every eval now goes through evalInTab: it refuses upfront when the tab's
renderer is gone (naming the crash reason so the caller knows to reload)
and otherwise races the eval against a 5s timer. Both backends share the
helper, and the bridge gets a 60s socket timeout as a backstop so no
future endpoint can wedge a tool call indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
It was the only browser tool with no output bound — console logs cap at
200 entries, clickables at 500 items, screenshots default to JPEG q70,
but outerHTML came back verbatim. A heavy page is 1-5MB of markup in a
single tool result, which blows the calling agent's context.

Truncates to 100KB by default with a notice naming the real size and the
max_bytes escape hatch. Also makes the bridge's request timeout
env-overridable so the never-responds path is testable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@frenchie4111
frenchie4111 merged commit 12edc00 into main Aug 26, 2026
1 check passed
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