Skip to content

Run on any CDP browser, and read the whole page - #3

Merged
waple0820 merged 2 commits into
mainfrom
feat/any-cdp-browser
Sep 24, 2026
Merged

waple0820 merged 2 commits into
mainfrom
feat/any-cdp-browser

Conversation

@waple0820

Copy link
Copy Markdown
Contributor

What

connect(endpoint) takes any CDP browser — a ws:// URL or an http(s):// address — and returns a Browser bound to one page. Lexmount becomes a thin layer on top (lexmount_session, with moli_session kept), and lexmount an optional extra.

Checked end to end

Five goals, each run twice, success judged by the final URL:

Browser Result
Moli, Lexmount Chrome image 10/10, 10/10
Cloudflare Kitesurf (authenticated) 10/10
Cloudflare Browser Run, Chromium 7/7 of runs that got a browser (free-plan daily quota)
Browserbase 10/10
Chrome, chrome-headless-shell, Playwright Chromium 10/10 each
Lightpanda 9/10
Obscura (no-render) 9/10
browserless, Steel, chromedp, Kernel, self-hosted 10/10 each
Selenium Grid 5/5

What each browser needed

  • No page at start (Lightpanda, Obscura, containers): create one at once instead of waiting 15s.
  • Container-internal address (ws://0.0.0.0:3000, container IP, no port): point it at the address that answered.
  • Handshake auth (Cloudflare, Airtop): headers=; user:pass@ in the URL becomes Basic auth.
  • Rate limits: a 429 asking for seconds is waited out; one asking for hours is reported instead of hanging.
  • Slow engines: no websocket keepalive — a busy engine cannot answer a ping.
  • Selenium Grid: selenium_session().
  • Submit buttons wait for the next document like links do (Lightpanda re-clicked for twenty steps).
  • Links that open a new page target are followed (Kitesurf).
  • Fields replaced after typing are found and filled again (Wikipedia's search box on Kitesurf submitted empty).

Reading the page

  • Page text is retrieved, not truncated: every row is returned and the rows that bear on the goal are kept (evidence.py, 20,000 chars). A 6,000-char prefix missed the answer on 4/4 long articles.
  • Same-name links to the same destination count once.
  • One selector query finds dead elements instead of an ancestor walk per element: identical output on five pages, 30–45% cheaper on Kitesurf.
  • One snapshot per step instead of two.
  • Decision requests retry dropped connections and 5xx.

connect(endpoint) is now the whole contract: a ws:// or http(s):// address
in, a Browser bound to one page out. Lexmount becomes a thin layer on top
(lexmount_session, moli_session kept as an alias), and the lexmount package
an optional extra. Checked end to end on Moli, Lexmount's Chrome image,
Cloudflare Kitesurf and Browser Run, Browserbase, Chrome, headless-shell,
Lightpanda, Obscura, and self-hosted browserless, Steel, chromedp, Kernel
and Selenium Grid.

What hosted and non-Chromium browsers needed, found by running them:

- A browser that starts with no page gets one created at once, instead of
  a 15s wait for a page that never comes.
- An address advertised from inside a container (ws://0.0.0.0:3000, a
  container IP, no port) is pointed back at the address that answered.
- headers= for services that authenticate the handshake; user:pass@ in the
  URL is sent as Basic auth.
- A 429 on connect is waited out when it asks for seconds and reported when
  it asks for hours (Cloudflare's exhausted daily quota: Retry-After 57691).
- No websocket keepalive: a slow engine busy on a long page cannot answer a
  ping, and the connection was dropped mid-run.
- selenium_session() for a Grid, which only hands out CDP per session.

Reading the page:

- Page text is retrieved, not truncated. The snapshot returns every row
  (a table row stays one row), and evidence.py keeps the rows that bear on
  the goal up to JEV_EVIDENCE_CHARS (20,000). A 6,000-character prefix
  missed the answer on four of four long articles.
- Same-name links to the same destination are one control, not "1 of 5".
- Goal words that name a kind of target ("article", "link") no longer
  rank candidates.
- Dead elements are found with one selector query instead of an ancestor
  walk per element; output is identical on five pages, and the snapshot
  costs 30-45% less on Kitesurf, which meters CPU per page.

Acting on it:

- A submit button is treated like a link: wait for the next document
  before reading. On Lightpanda the agent read the page being left and
  spent twenty steps re-clicking.
- A link that opens on a new page target (Kitesurf) is followed there.
- A field the page replaces after it is typed into (Wikipedia's search box
  on Kitesurf) is found again and typed into again, so the form does not
  submit an empty search.
- One snapshot per step: the read taken after an action is reused as the
  next step's observation.
- Decision requests retry dropped connections and 500/502/504, which ended
  one run in ten on otherwise healthy browsers.
- The text model is only required when a step types.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @waple0820's task in 3m 30s —— View job


PR Review

  • Gather context
  • Read PR diff
  • Review changed files
  • Post findings

Two bugs found; no security issues.

1. model.py:81 — Unreachable raise

    raise RuntimeError(f"Model unavailable ({last})")

This line is dead code. On the final attempt (final = True), the response.status_code in RETRY_STATUS and not final branch is skipped, so a retry-status response falls through to if response.is_error on line 74 and raises there. Every code path inside the loop either returns or raises — the loop never exits normally. The last variable accumulated for this message is never actually surfaced. Fix this →

2. browser.py:297 — Old CDP session leaked in _follow_new_page()

self.session, self.target = attached["sessionId"], page["targetId"]

self.session is overwritten without calling Target.detachFromTarget for the prior session ID. The replaced session becomes a zombie for the rest of the websocket connection's lifetime. This is only observable when _follow_new_page() fires repeatedly (each new-tab navigation leaks a handle), but browsers that enforce session limits would eventually refuse new attaches. Fix this →

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated Claude review completed. Review run: https://github.com/lexmount/jev-nolayout/actions/runs/35972709266. Please check Claude's inline feedback and summary comment for details.

The last line of post() could never run: every attempt returns or raises, so the reason
gathered across retries was never reported. A retried status now says how many attempts
it took, and the loop ends in an explicit unreachable assertion.

Following a link onto a new page target replaced the session without detaching the old
one, leaking a CDP session per followed link. Detach it first; a browser that caps
sessions would otherwise refuse a later attach.

Signed-off-by: waple0820 <feng.wang@lexmount.com>
@waple0820

Copy link
Copy Markdown
Contributor Author

Both findings were real, and both are fixed in the latest commit:

  1. Unreachable raise in post() — every attempt returns or raises, so the reason gathered across retries was never reported. A retried status now reports the attempt count (checked against a server that always answers 500: Model provider returned HTTP 500 after 4 attempts: boom), and the loop ends in an explicit unreachable assertion.
  2. Leaked session in _follow_new_page() — the old session is now detached before switching. Re-checked on Kitesurf, the browser that exercises this path: both link-following tasks still pass.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Claude finished @waple0820's task in 2m 16s —— View job


PR Review

  • Gather context
  • Read PR diff
  • Review changed files
  • Post findings

Both bugs from the prior review are fixed in this push. No new issues found.

model.py:474 — raise RuntimeError("Model unavailable") replaced with raise AssertionError("unreachable"). Correct: the unreachable sentinel now signals intent rather than silently masking a logic error.

browser.py:253-254 — Target.detachFromTarget is now called for the old session before self.session is overwritten in _follow_new_page(), with contextlib.suppress(RuntimeError) to tolerate already-gone sessions. Session leak is gone.

No other bugs or security issues identified in the diff.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated Claude review completed. Review run: https://github.com/lexmount/jev-nolayout/actions/runs/35973399596. Please check Claude's inline feedback and summary comment for details.

@waple0820
waple0820 merged commit bbd6407 into main Sep 24, 2026
1 check passed
@waple0820
waple0820 deleted the feat/any-cdp-browser branch September 24, 2026 13:14
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