Skip to content

Reach a Chrome that serves only the browser WebSocket - #9

Open
srizzo wants to merge 3 commits into
sblattj:mainfrom
srizzo:feat/browser-ws-transport
Open

srizzo wants to merge 3 commits into
sblattj:mainfrom
srizzo:feat/browser-ws-transport

Conversation

@srizzo

@srizzo srizzo commented Sep 19, 2026

Copy link
Copy Markdown

cdp-toolkit discovers targets over HTTP (/json/version, /json/list) and then dials a per-target socket at /devtools/page/<id>. A Chrome whose remote debugging was enabled at runtime via the chrome://inspect/#remote-debugging toggle serves neither: /json/* returns 404 and /devtools/page/* returns 403. The only thing it serves is the single browser-level WebSocket at /devtools/browser/<uuid>. Against such a browser the toolkit cannot connect at all.

This PR adds that transport.

The endpoint shape this addresses

Request HTTP-discovery Chrome chrome://inspect toggle Chrome
/json/version 200 404
/json/list 200 404
/devtools/page/<id> 101 403
/devtools/browser/<uuid> 101 101

Against a real, heavily-loaded browser

186 pages listed in 0.29s (581 targets total: page 186, tab 188, iframe 126, worker 48, service_worker 11, browser_ui 18, background_page 2, shared_worker 2).

The headline number: a heavy dashboard tab that costs 59.7s to attach under an MCP that attaches to every target at connect answers here in 0.01s. take_snapshot on it: 0.04s. Five consecutive drives across different real tabs: 0.09s. Whole run: 8.7s wall clock, every check under a hard 60s budget.

No regression on the existing HTTP path

Against a disposable headless Chrome on --remote-debugging-port (/json/* = 200), the repo's own live smoke: 13/13 checks passed. Plus:

  • bun test → 1010 pass / 2 skip / 0 fail (1012 tests, 39 files)
  • tsc --noEmit → exit 0
  • bun run mcp:smoke → OK, 49 tools on both protocol eras

The wedge property, re-proven for a shared socket

This could not be inherited from the repo's existing bench:wedge: that benchmark gives every page its own socket, where a hung renderer can only block the one socket dialed into it. This transport puts every page on ONE socket — precisely the arrangement where a naive implementation head-of-line blocks. So bun run browser-ws:wedge re-proves it, 5s bound:

  (victim navigate returned in 5.01s — 'Page.navigate' timed out after 5000ms)
  ok   stuck tab rejects at the bound (no hang) — 5.00s
  ok   witness tab stays fast while the other is stuck — 0ms, value="witness"
  ok   list_pages stays fast with a stuck tab open — 0ms, 3 pages
  ok   recovery after closing the bricked tab — 0ms
PASS — one stuck tab does not wedge the shared browser socket.

The bound is enforced per command id inside CdpConnection.send, not per socket, which is why sharing the socket costs nothing.

Scale, on a fixture

TABS=200 bun run browser-ws:smoke against a proxy that serves the toggle-Chrome shape (404/403/101): 201 pages in 0.02s, 415 targets across 7 types.

How it works

  • Discovery via Target.getTargets over the browser WebSocket instead of HTTP /json/list. Target.getTargets returns metadata and attaches to nothing, which is what preserves the repo's headline property.
  • Per-target work via Target.attachToTarget with flatten: true, giving flat sessions multiplexed over that one socket by sessionId.
  • The one-target-per-call guarantee is unchanged: listing attaches to zero targets, and only the named target is ever attached.

Implementation points worth stating:

  • src/cdp/endpoint.ts — transport detection.
  • src/cdp/session.ts — a reference-counted shared browser socket with concurrent-first-borrow deduplication, plus openSession(targetId).
  • src/client.ts — listTargets() issues the HTTP request first and unconditionally, and only falls back to the browser socket after it fails; on fallback failure it rethrows the ORIGINAL HTTP error. This is why the existing HTTP path is byte-for-byte unchanged.
  • PageConnection is a structural interface over the two connection kinds, so the 7 tool modules work against either transport without narrowing.
  • Target.getTargets' default filter is NOT "everything" — measured on Chrome 153 the default returned 4 targets and filter: [{}] returned 6, the difference being exactly the iframe/worker types. The patch uses filter: [{}] so the documented frame: and worker: selector arms keep working.
  • Nothing is auto-discovered. The browser WS URL comes from CDP_BROWSER_WS, or from a DevToolsActivePort file in a profile dir the operator names via CDP_USER_DATA_DIR. No default profile is ever scanned, because merely connecting raises Chrome's "Allow remote debugging?" modal on the user's screen.

Two new test commands

  • bun run browser-ws:smoke — end-to-end over a proxy fixture that reproduces the 404/403/101 shape. TABS=N scales the tab count.
  • bun run browser-ws:wedge — the shared-socket wedge proof above.

Both spawn their own disposable headless Chrome with a mkdtemp profile and kill it on exit. Neither touches any existing browser.

Honestly

This is ~1000 unsolicited lines, and it changes the page-level helper type from CdpConnection to the structural PageConnection across 8 modules. Happy to split it into smaller PRs, drop parts, or adjust anything you'd prefer done differently. The measurements are the argument — I'm not claiming more than they show.

🤖 Generated with Claude Code

A Chrome that had debugging enabled at runtime, through the toggle on
chrome://inspect/#remote-debugging, serves a different transport than one
started with --remote-debugging-port. Measured on Chrome 153: the browser
socket upgrades (101), a per-target socket is refused (403), and /json/version
and /json/list both 404. That is what a user gets when they attach to the
browser they were already using rather than relaunching it, and the toolkit
could not reach it at all.

Discovery now falls back to Target.getTargets over that one socket, and each
call reaches its page through a flat Target.attachToTarget session on it. The
HTTP path is unchanged: listTargets issues its original GET /json/list first
and unconditionally, and only a request that did not answer reaches the
fallback, so there is no added round-trip and no added failure mode.

The one-target-per-call guarantee holds. Target.getTargets is metadata-only and
attaches to nothing, so a 200-tab browser costs one round trip rather than one
attach per tab (measured: 201 pages, 411 targets, 0.03s). The per-command
timeout is bounded per command id rather than per socket, so sharing the socket
does not let one stuck tab block the rest — browser-ws:wedge blackholes a tab
and shows it rejecting at the 5s bound while a witness tab, list_pages, and a
freshly opened tab all answer in under 1ms on that same socket.

Nothing is auto-discovered. A runtime-toggled Chrome raises a modal consent
prompt for each new client, granting full access to a logged-in session, so the
browser is named explicitly with CDP_BROWSER_WS or CDP_USER_DATA_DIR and the
default profile is never scanned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

prxref automated review: Approved

PR: Reach a Chrome that serves only the browser WebSocket · files reviewed: 17

🟥 0 error · 🟧 0 warning · 🟦 0 outofscope

No findings — nice work.


Reviewed by prxref · model=z-ai/glm-5.3-flash · 40806 tok · 83.2s

srizzo and others added 2 commits September 19, 2026 15:46
Three problems review found in the new code:

detectEndpoint cached the promise, not the result, so the first attempt
losing a race with a still-starting Chrome left every later call holding
that same rejection — including listTargets' fallback, which would then
report a dead endpoint for the life of the process. Evict on rejection;
a resolved detection is still cached exactly once.

The smoke test's fail() called process.exit, which does not unwind, so
the catch that kills Chrome and removes the mkdtemp profile never ran.
Every assertion failure leaked a browser and a profile directory. Throw
instead and let the existing handler do its job.

The wedge test waited the full 30s for an endpoint Chrome would never
print if it had already died. Reject on exit, as the smoke test does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r runs

The handshake is bounded because a dead port would otherwise stall the
whole detection, but the /json/version fetch right below it was not, so
a host that accepts TCP and never answers hung detectEndpoint — and with
it listTargets' fallback. It now settles in 5s.

The failure message said no socket was found in "the default Chrome
profile(s)", asserting a search the module deliberately never performs:
scanning a default profile is what raises Chrome's consent dialog on the
user's screen. Say what actually happened instead.

Also in the fixtures: the proxy's open() awaited a promise only onopen
resolved, so a failed upstream handshake queued client messages forever;
the smoke test computed the page-socket probe but never asserted on it,
so a proxy that upgraded /devtools/page/* would have passed the very
check that establishes the endpoint is browser-ws-only; and freePort was
dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@srizzo

srizzo commented Sep 19, 2026

Copy link
Copy Markdown
Author

All eight findings from the two review passes are addressed in 6f40976 and fa38950. Each one was real; notes and evidence below.

First pass

🟧 detectEndpoint caches a rejected promise — correct, and the worst of the set: a first attempt losing a race with a still-starting Chrome would have made every later call report a dead endpoint for the life of the process. Now evicts on rejection. Recovery, with no resetEndpointCache between the calls:

1st call -> rejected: no DevTools endpoint at http://127.0.0.1
2nd call -> resolved: browser-ws ws://127.0.0.1:9/devtools/browser/abc

The dedup that matters is intact — two successful calls still return the same object, so detection isn't repeated.

🟧 fail() bypasses cleanup — correct. process.exit doesn't unwind, so the catch that kills Chrome and removes the mkdtemp profile never ran. It throws now. Verified by temporarily forcing a failure at the list_pages assertion:

FAILED: FORCED FAILURE to exercise the fail() cleanup path
leftover profile dirs: 0
leftover chrome procs: 0

🟦 No exit handler on Chrome in the wedge test — fixed; it now rejects on exit like the smoke test does.

Second pass

🟧 /json/version fetch has no timeout — correct, and you're right that it contradicted the module's own stated reason for bounding the handshake. Now bounded by AbortSignal.timeout(5_000). Against a server that accepts TCP and never answers:

settled in 5.00s

🟧 Proxy open() hangs if upstream never opens — correct. It now resolves on error/close too. The discriminating evidence: with a probe printing OPEN-HANDLER-COMPLETED at the end of the handler, against an upstream killed before the client connects —

old proxy:  client socket closed after 0.00s
new proxy:  OPEN-HANDLER-COMPLETED
            client socket closed after 0.00s

The client socket closed either way, which is why this is easy to miss; the handler itself only completes with the fix.

🟦 Error message claims default profiles were searched — the most worth fixing of the three marked out-of-scope, because the claim was backwards. Not scanning default profiles is the deliberate safety property here: connecting to a browser the operator didn't name is what raises Chrome's "Allow remote debugging?" modal on their screen. The message now says what happened:

no profile was searched — CDP_USER_DATA_DIR is unset, and no profile is
ever scanned unless you name one.

🟦 Fixture check never asserts the page socket is 403 — correct, and it undercut the one check that establishes the endpoint is browser-ws-only. Now asserted.

🟦 freePort() is dead code — deleted.

Verification after both commits

  • bun run browser-ws:smoke — PASS
  • bun run browser-ws:wedge — PASS (stuck tab rejects at the 5s bound; witness tab, list_pages, and recovery all 0ms)
  • bun test — 1010 pass / 2 skip / 0 fail
  • tsc --noEmit — exit 0

🤖 Generated with Claude Code

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