Repository navigation
fix(cdp): a slow link no longer drops the attached browser mid-call - #138
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withThe tab is still alive, and the daemon logs nothing.
Root cause
Two faults, one after the other:
_bridge(andCdpUpstream) 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 1011keepalive ping timeoutwithout logging anything. Playwright sees its wholeBrowserdisconnect, so every Page raisesTargetClosedError. The daemon's own session upstream is a separate ws and stays up, which is why its log looks clean._target_closed_responsewent into_rebind_dead_page. That cleared the live tab from the ledger, bound onNone(_on_facade_deadhad already dropped the context, hence'NoneType' object has no attribute 'new_page'), and then recycled the executor, sostatewas 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
daemon/server/ws_liveness.py: liveness for the upstream CDP websockets (facade bridge andCdpUpstream).data_received), because the big reply is a single message.TargetClosedErrorcomes with a disconnectedBrowser, the executor keeps the tab binding andstate. 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_large_reply_on_a_slow_link_keeps_the_browser_connected(12k-item page →aria_snapshot→ fill/click)TargetClosedError: Page.aria_snapshot ...+'NoneType' object has no attribute 'new_page'test_dropped_bridge_reconnects_to_the_same_tab_with_state(drop the bridge mid-call)STATE NoneSTATE kept, same tab (windowmarker 42), still 1 tabOther checks:
mise run lint: green.mise run test: daemon 981 passed / 105 skipped, skill 338 passed, evals 13/13.test_human_scroll[headless]also fails on main.test_issue116_slow_commit_goto(extension backend) passes 3/3 in isolation and when rerun afterhuman_scroll.test_l2_background_renderandtest_repro_extension_reload_orphanpassed on rerun.Not fixed here
On a link this slow, human-input
fill/clicktakes more than 30 s because it makes many sequential CDP round trips. That is a separate performance problem. The e2e useshuman=Falseso it doesn't hide the bug fixed here.🤖 Generated with Claude Code