fix(native): resume auto-attached targets before the first renderer command
Problem
agent-browser click on a target="_blank" link stalls for 30 seconds and then fails, tearing the whole browser down with it, whenever network controls are active (--allowed-domains or proxy credentials):
$ export AGENT_BROWSER_SOCKET_DIR=/tmp/ab-pr-verify
$ export AGENT_BROWSER_ALLOWED_DOMAINS="bilibili.com,www.bilibili.com,search.bilibili.com,space.bilibili.com"
$ agent-browser --session pr1 open "https://search.bilibili.com/all?keyword=%E7%BB%BF%E5%AF%BC%E5%B8%88%E5%8E%9F%E8%B0%85%E4%BD%A0%E4%BA%86"
$ agent-browser --session pr1 snapshot -i # UP-card link ref, e.g. @e121
$ time agent-browser --session pr1 click @e121 --json
{"success":false,"data":null,"error":"Failed to install browser network controls: CDP command timed out: Page.enable"}
real 0m30.148sThe failure is specific to renderer-initiated opener popups. Same-tab navigation clicks (91ms), direct open (577ms) and click --new-tab (73ms) are unaffected, and a two-line local localhost popup fails identically to bilibili, so page weight is irrelevant.
Root cause
With network controls active the daemon installs browser-level Target.setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: true}) (enable_browser_auto_attach_pub, browser.rs:889). A renderer-initiated target="_blank" popup therefore attaches paused, before its navigation has started: url: "", no execution context (Target.attachedToTarget reports waitingForDebugger: true).
The attach handler (apply_drained_events → drained.attached_page_sessions, actions.rs:1445) then ran the shared setup in this order:
prepare_domains(browser.rs:910):Page.enableis the first renderer-bound command.install_network_controls_for_session→install_domain_filter(now split by this change):Fetch.enable+Page.addScriptToEvaluateOnNewDocument+Runtime.evaluate.Runtime.runIfWaitingForDebuggerlast (browser.rs:1081).
A frozen renderer never answers Page.enable, so the command rides the hardcoded 30s timeout in cdp/client.rs (client.rs:523) and close_after_network_control_failure closes the browser. The same enable-before-resume order was shared by install_active_network_controls (actions.rs:4021), the concurrent Target.attachedToTarget handler (actions.rs:988, via prepare_auto_attached_session), and the iframe attach path.
Raw-CDP confirmation against Chrome for Testing 155: on the real-click popup Page.enable gets no answer at all, while Runtime.runIfWaitingForDebugger answers in 4ms and every later command (including the filter's Runtime.evaluate) then succeeds. A popup created with Target.createTarget answers Page.enable in 1ms because it is never paused this way, which is why only the opener-popup path breaks.
Related: #1651 (same install path, Page.enable fails when the session vanishes), #2031 (same root cause; the author recommends resuming before installing controls), #1528 (a renderer-bound command sent to a renderer that cannot answer).
Fix
Three coordinated changes, all in the shared setup helpers:
BrowserManager::prepare_domains(browser.rs:910) now sendsRuntime.runIfWaitingForDebugger(fire-and-forget) beforePage.enable. One change covers the popup handler,install_active_network_controls, the iframe handler andenable_domains. The command is a no-op on a target that is not waiting, and it is sent without waiting for a response so a renderer that cannot answer at all (a discarded tab, #1528) still fails on the command that matters instead of stalling on the resume.install_network_controls_for_session(actions.rs:3919) is reordered intoinstall_fetch_interception→prepare_domains→install_domain_filter_script_for_session.Fetch.enablemust land before the resume: the browser process answers it even while the renderer is frozen (verified: 1ms on awaitingForDebuggerpopup session), and a renderer-initiated popup issues its first navigation request the moment it is resumed. With interception installed first, that first request is paused and resolved byresolve_fetch_paused, so a disallowed host stays fail-closed. The in-page filter script still follows the resume becausePage.addScriptToEvaluateOnNewDocumentandRuntime.evaluateneed a live renderer.- The concurrent
Target.attachedToTargethandler (actions.rs:988) runs the same ordered setup on its own task. Inline, itsPage.enableawait blocked the fetch event loop against its ownFetch.requestPausedresolver: once interception is installed before the resume, the paused first request can only be resolved by that same loop, so the setup deadlocked with itself until the 30s timeout. The handler still resumes attached targets immediately when no controls are active, exactly as before (that resume is what keeps cross-origin iframes loading during a navigation).
waitForDebuggerOnStart stays enabled. Pausing each auto-attached target before its first request is the containment guarantee the network controls are built on; disabling it would trade a hang for a containment hole.
Trade-off
Resuming before Page.addScriptToEvaluateOnNewDocument is installed leaves a narrow window in which the popup's first document exists before the in-page half of the filter runs. What is not widened: every request, including the popup's first navigation, is still intercepted by Fetch.enable (installed before the resume) and resolved by resolve_fetch_paused, so a disallowed host is blocked before any bytes leave the machine, and addScriptToEvaluateOnNewDocument still covers the popup's next document. The window only affects the JS API patching (fetch/XHR/WebSocket/EventSource/sendBeacon/RTCPeerConnection) in that first document, and Runtime.evaluate re-applies the patch to the current context as soon as one exists. Flagging it explicitly in case a follow-up wants to close it entirely, e.g. by waiting for Runtime.executionContextCreated before resuming.
Verification
Tested against agent-browser 0.39.1 on Linux (Chrome for Testing 155), built from main at 468c0526 and from this branch.
Real bilibili target="_blank" click, native CLI, before and after this patch:
before: real 0m30.148s {"success":false,"error":"Failed to install browser network controls: CDP command timed out: Page.enable"}
after: real 0m0.187s {"success":true,"clicked":"@e121"}
after: tab list -> [t2] space.bilibili.com/202224425 - https://space.bilibili.com/202224425Local fixture server (127.0.0.1:8787, landing page with a target="_blank" link, AGENT_BROWSER_ALLOWED_DOMAINS=localhost):
- allowed-host popup click:
real 0m0.085s, success, popup registered athttp://localhost:8787/popup; - disallowed-host popup click (
http://127.0.0.1:8787/leak):real 0m0.086s, success, popup shows the block page ("Navigation to 127.0.0.1 is not allowed by domain filter"), the fixture server logged no/leakrequest, and the browser stayed up.
Tests:
- new unit test
prepare_domains_resumes_paused_target_before_page_enablepins the command order on a paused session against a fake CDP server that models the frozen renderer (and answersFetch.enablewhile frozen, like Chrome); - new unit test
network_controls_install_fetch_interception_before_resumepins the full order:Fetch.enable,Runtime.runIfWaitingForDebugger,Page.enable,Runtime.enable,Network.enable,WebMCP.enable,Target.setAutoAttach,Page.addScriptToEvaluateOnNewDocument,Runtime.evaluate; - new ignored e2e test
e2e_domain_filter_target_blank_popup_click_resumes_firstdoes a realtarget="_blank"click under--allowed-domainsand asserts sub-second completion, popup registration and navigation, plus the disallowed-host popup staying blocked fail-closed. It fails onmainwith the 30sPage.enabletimeout and passes in ~1.1s with the fix; cargo test(default suite,--profile cias in CI): 1495 passed, 0 failed, including alle2e_domain_filter_*,e2e_a11y_*, and auto-attach/new-tab e2e tests;cargo clippy -- -D warnings: no new findings (the two findings on this toolchain,chrome.rs:386andclient.rs:1139, reproduce on unpatchedmain).
One caveat for reproducibility: during development, e2e_domain_filter intermittently failed with a 30s Page.navigate timeout when the batch ran while leftover Chrome processes from an aborted run were still alive (machine had ~17 chrome processes). With a clean process table the batch passes repeatably (5/5 on main, 4/4 on this branch after cleanup, plus the full-suite runs above); the failure did not reproduce in isolation and is attributed to machine load, but flagging it in case it is seen in CI.
@k997 is attempting to deploy a commit to the Vercel Labs Team on Vercel.
A member of the Team first needs to authorize it.