Skip to content

Reach a Chrome that serves only the browser WebSocket (from #9) - #10

Open
sblattj wants to merge 3 commits into
mainfrom
browser-ws-transport
Open

sblattj wants to merge 3 commits into
mainfrom
browser-ws-transport

Conversation

@sblattj

@sblattj sblattj commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Carries over #9 by @srizzo unchanged: same three commits, same tree, his authorship kept.

#9 had to be re-opened from this repo. This repo's history was rewritten, which moved main under the PR, and GitHub does not let maintainers push history that touches workflow files into a fork branch. Full description, measurements and review discussion are on #9.

Co-authored-by: Samuel Rizzo [email protected]

srizzo and others added 3 commits October 12, 2026 13:46
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

prxref automated review: Approved

PR: Reach a Chrome that serves only the browser WebSocket (from #9) · files reviewed: 17

🟥 0 error · 🟧 7 warning · 🔍 0 spec · ⬜ 1 outofscope

  • 🟧 package.json:— — npm run browser-ws:smoke is added but no CI job runs it
  • ⬜ src/cdp/endpoint.ts:45 — Unused homedir import
  • 🟧 src/cdp/endpoint.ts:107 — Raw WebSocket opened outside client.ts primitives
  • 🟧 src/client.ts:277 — Transient /json/list failure permanently latches browser-ws transport
  • 🟧 test/browser-ws-smoke.ts:— — test/browser-ws-smoke.ts is added but no CI job runs it
  • 🟧 test/browser-ws-smoke.ts:50 — Chrome and profile leak when startup fails before the try block
  • 🟧 test/browser-ws-wedge.ts:— — test/browser-ws-wedge.ts is added but no CI job runs it
  • 🟧 test/browser-ws-wedge.ts:87 — Chrome and profile leak when startup fails before the try/finally

Inline comments: 5 of 8 findings (3 anchors rejected by the forge).


Reviewed by prxref · model=z-ai/glm-5.3-flash · 48373 tok · 42.8s

Comment thread test/browser-ws-smoke.ts
}
}

const profile = await mkdtemp(join(tmpdir(), "cdp-browser-ws-smoke-"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 🟧 [WARNING] Chrome and profile leak when startup fails before the try block (test/browser-ws-smoke.ts:50)

The await of the upstreamWs promise (lines 65-77) and the proxy start (line 81) run before the try at line 90, and cleanup() is only called inside try/catch. If Chrome exits early or the 30s endpoint timeout rejects, the top-level await throws with no cleanup: the spawned Chrome (line 50) keeps running and the mkdtemp profile (line 49) is never removed.


Reviewed by prxref · model=z-ai/glm-5.3-flash

Comment thread test/browser-ws-wedge.ts
});
chrome.on("exit", (code) => reject(new Error(`Chrome exited (${code}) before reporting an endpoint`)));
});
const proxy = await startBrowserWsOnlyProxy(`http://${new URL(upstreamWs).host}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 🟧 [WARNING] Chrome and profile leak when startup fails before the try/finally (test/browser-ws-wedge.ts:87)

The upstreamWs await (lines 74-86) and proxy start (line 87) precede the try/finally, whose finally is the only place chrome.kill/rm happen. A Chrome startup failure (exit or 30s timeout) rejects the top-level await before finally exists, leaking the spawned Chrome process and the temp profile from line 60.


Reviewed by prxref · model=z-ai/glm-5.3-flash

Comment thread src/cdp/endpoint.ts
* and a dead port would otherwise stall the whole detection on a connect that
* never resolves.
*/
function handshake(url: string, timeoutMs: number): Promise<boolean> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 🟧 [WARNING] Raw WebSocket opened outside client.ts primitives (src/cdp/endpoint.ts:107)

Spec: "Build on src/client.ts primitives: never open a WebSocket yourself." (CONTRIBUTING.md:20). handshake constructs new WebSocket(url) directly instead of going through CdpConnection/openBrowser, the only in-repo standards document provided for this chunk forbids opening a WebSocket outside client.ts.


Reviewed by prxref · model=z-ai/glm-5.3-flash

Comment thread src/client.ts
// lookup came up empty.
throw httpError;
});
knownTransport = "browser-ws";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 🟧 [WARNING] Transient /json/list failure permanently latches browser-ws transport (src/client.ts:277)

Any single /json/list failure (e.g. a transient 5xx or network blip on a --remote-debugging-port Chrome) falls into getTargetsOverBrowserWs, which can succeed over the browser socket, and then sets knownTransport = "browser-ws" for the process lifetime. From then on every listing skips /json/list and reports empty webSocketDebuggerUrl, silently switching openPage and all callers to session transport after a one-off error.


Reviewed by prxref · model=z-ai/glm-5.3-flash

Comment thread src/cdp/endpoint.ts
* Resolved at connect time for that reason, never memoized across a restart.
*/
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 ⬜ [OUTOFSCOPE] Unused homedir import (src/cdp/endpoint.ts:45)

import { homedir } from "node:os"; is added but nothing in the module references it — profile lookup reads CDP_USER_DATA_DIR directly. Dead import under a strict-tsc repo rule.


Reviewed by prxref · model=z-ai/glm-5.3-flash

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.

2 participants