Skip to content

fix(cdp): a slow link no longer drops the attached browser mid-call - #138

Merged
broven merged 1 commit into
mainfrom
fix/cdp-attach-target-closed
Oct 10, 2026
Merged

broven merged 1 commit into
mainfrom
fix/cdp-attach-target-closed

Conversation

@broven

@broven broven commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Symptom

session new --backend=cdp --attach=<http url> to a remote Chromium over a slow, jittery link (tailnet, 0.3–1 s RTT) intermittently fails mid-script with

TargetClosedError: Locator.aria_snapshot: Target page, context or browser has been closed
[fix] ... re-binding a fresh tab failed (... 'NoneType' object has no attribute 'new_page' ...). The executor recycled itself.

The tab is still alive, and the daemon logs nothing.

Root cause

Two faults, one after the other:

  1. The facade bridge's upstream ws is killed by its own keepalive. _bridge (and CdpUpstream) used websockets' built-in keepalive (ping_interval=20, ping_timeout=20). The pong travels behind the data in the same TCP stream. One large CDP reply on a slow link, such as a ~1.5 MB aria snapshot, holds the pong back for more than 20 s while bytes keep arriving. websockets then closes the bridge with 1011 keepalive ping timeout without logging anything. Playwright sees its whole Browser disconnect, so every Page raises TargetClosedError. The daemon's own session upstream is a separate ws and stays up, which is why its log looks clean.
  2. The executor treats "my connection dropped" as "my tab died". _target_closed_response went into _rebind_dead_page. That cleared the live tab from the ledger, bound on None (_on_facade_dead had already dropped the context, hence 'NoneType' object has no attribute 'new_page'), and then recycled the executor, so state was lost.

Target churn (a second connection creating, activating and closing targets mid-session) was tested as the other hypothesis. With churn alone and no slow link, 8 rounds and 237 churn cycles stayed green, so it is not the cause.

Fix

  • New daemon/server/ws_liveness.py: liveness for the upstream CDP websockets (facade bridge and CdpUpstream).
    • A connection is declared dead only when no byte at all has arrived for 20 s after a ping.
    • Bytes are counted at the transport level (data_received), because the big reply is a single message.
    • Idle connections are still pinged, which keeps NAT and proxy timers fed.
  • facade: every bridge end is logged at info, with the close frames sent and received on both sides. The next drop will say why it happened.
  • executor: if a TargetClosedError comes with a disconnected Browser, the executor keeps the tab binding and state. It tells the agent to retry the call, and the next call reconnects to the same tab. A real tab close still takes the existing rebind path.

The extension backend is not touched. There is no focus-related code.

Evidence

New E2E tests/daemon/e2e/test_cdp_attach_slow_link.py. The rig is a TCP forwarder in front of Chrome for Testing that adds 100–500 ms per-chunk jitter and caps the link at 30 KB/s, plus target churn on a second CDP connection. The artifact is _artifacts/cdp-attach-slow-link.json.

test main (345521d) this branch
test_large_reply_on_a_slow_link_keeps_the_browser_connected (12k-item page → aria_snapshot → fill/click) FAIL: TargetClosedError: Page.aria_snapshot ... + 'NoneType' object has no attribute 'new_page' PASS: snapshot of 1,622,835 chars in 72 s, ~3 MB through the link, 195 churn cycles
test_dropped_bridge_reconnects_to_the_same_tab_with_state (drop the bridge mid-call) FAIL: same rebind error; next call STATE None PASS: retry hint, then STATE kept, same tab (window marker 42), still 1 tab

Other checks:

  • mise run lint: green.
  • mise run test: daemon 981 passed / 105 skipped, skill 338 passed, evals 13/13.
  • Related e2e (facade, issue86, issue136, daemon restart adoption, page binding, executor fail-stop, new test): 19 passed.
  • Full e2e: 103 passed, 4 failed. None of the failures is caused by this change:
    • test_human_scroll[headless] also fails on main.
    • test_issue116_slow_commit_goto (extension backend) passes 3/3 in isolation and when rerun after human_scroll.
    • test_l2_background_render and test_repro_extension_reload_orphan passed on rerun.

Not fixed here

On a link this slow, human-input fill/click takes more than 30 s because it makes many sequential CDP round trips. That is a separate performance problem. The e2e uses human=False so it doesn't hide the bug fixed here.

🤖 Generated with Claude Code

On a remote `--attach` over a slow, jittery link (tailnet relay, 0.3-1 s
RTT), agent scripts intermittently died with `TargetClosedError: Target
page, context or browser has been closed`, and the rebind then failed with
`'NoneType' object has no attribute 'new_page'`. The tab stayed alive and
the daemon logged nothing.

Root cause: the facade bridge's upstream websocket used websockets' own
keepalive (ping_interval=20, ping_timeout=20). A pong travels behind the
data, so one large CDP reply (a ~1.5 MB aria snapshot) on a slow link holds
it back past 20 s although bytes arrive the whole time. websockets then
closed the bridge with 1011, silently; Playwright saw its whole browser
disconnect. The executor then treated "my connection dropped" as "my tab
died": it cleared the live tab from the ledger, re-bound on a context that
no longer existed (None.new_page) and recycled itself, losing `state`.

- ws_liveness: upstream CDP websockets (facade bridge and CdpUpstream) are
  declared dead only when no byte at all has arrived for 20 s after a ping;
  bytes are counted at the transport, since the big reply is one message.
  Idle connections are still pinged.
- facade: every bridge end is logged with both sides' close frames.
- executor: a TargetClosedError caused by a disconnected Browser keeps the
  tab binding and `state`, tells the agent to retry, and reconnects to the
  same tab on the next call.

E2E (tests/daemon/e2e/test_cdp_attach_slow_link.py, artifact
cdp-attach-slow-link.json) reproduces both halves locally with a jittery,
30 KB/s TCP forwarder in front of Chrome for Testing plus target churn on a
second connection; both tests fail on main with the field signature and
pass here.

Co-Authored-By: Claude <noreply@anthropic.com>
@broven
broven merged commit 4b54676 into main Oct 10, 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