From 286f185872645cd545a6884ef6945838f6ca08c3 Mon Sep 17 00:00:00 2001 From: LawyZheng Date: Sat, 18 Jul 2026 02:47:58 +0800 Subject: [PATCH 1/3] Fix two remote-CDP defects: set_content hang and premature interaction timeouts Page.set_content() over connect_over_cdp deterministically hung: the Rust core issued the Page.setDocumentContent CDP command, which never returns on a remote-CDP-attached page (the browser does not send the command result, so the call blocks until timeout on reused and freshly-created pages alike). Evaluate and data-URL navigation succeed over the same attached session, and rustwright's response demux matches by command id, so the hang is the command itself. Materialize the document with document.open/write/close via Runtime.evaluate instead (as Playwright does); this delivers the byte-identical document over the transport the attach path is proven to support. Remove the now-unused PageInner::main_frame_id helper (set_content was its only caller). Locator actionability/state polling capped each CDP probe at a fixed 1000 ms and, in the actionability paths, did not catch a probe timeout. A single probe issues several CDP round trips; over a high-latency remote-CDP transport those exceed 1000 ms, so every probe timed out and the raw TimeoutError aborted the whole click/select_option/fill after ~0.3-0.4 s even though the outer 30 s deadline was far off. Give each probe the full remaining action budget (new helper _actionability_probe_timeout) and treat a per-probe timeout as a transient retry until the real deadline, matching Playwright. Polling cadence is still driven by _sleep_until_next_poll, so widening the bound does not busy-loop. Add regression tests: set_content on adopted and new connect_over_cdp pages, and an actionability probe that tolerates slow remote-CDP round trips (fails with the old 1000 ms cap, passes now). Co-Authored-By: Claude Opus 4.8 --- python/rustwright/sync_api.py | 57 ++++++++++++++++------ src/lib.rs | 44 +++++++---------- tests/test_rustwright_sync_api.py | 78 +++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index ccc7102..3872afa 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -4607,6 +4607,19 @@ def _sleep_until_next_poll(deadline: float, interval: float = 0.02) -> None: time.sleep(min(interval, remaining)) +def _actionability_probe_timeout(remaining_ms: float, timeout_disabled: bool = False) -> float: + # Budget a single actionability/state probe with the full remaining action time rather + # than a small fixed cap. A cap shorter than one remote-CDP round trip made every probe + # time out and abort the action while the outer deadline was still far away (observed on + # click/select_option over connect_over_cdp attach). Polling cadence is driven by + # _sleep_until_next_poll, not this bound, so widening it does not busy-loop. For an + # infinite wait (timeout<=0) keep a bounded cadence so owner-availability and locator + # handlers are still re-checked periodically. + if timeout_disabled: + return min(remaining_ms, 1_000.0) + return max(remaining_ms, 1.0) + + def _handle_event_wait_timeout(owner: Any, event: str, timeout_display: str, deadline: Optional[float]) -> None: if owner is not None: _raise_if_owner_unavailable(owner, use_close_reason=True) @@ -21785,16 +21798,26 @@ def _wait_for_single( while True: _raise_if_owner_unavailable(self._page, method=method) remaining_ms = max((deadline - time.monotonic()) * 1000, 1.0) - command_timeout = min(1_000.0 if timeout_disabled else max(timeout_ms, 1.0), remaining_ms, 1_000.0) + command_timeout = _actionability_probe_timeout(remaining_ms, timeout_disabled) self._page._run_locator_handlers(deadline) - last_info = self._target_state( - command_timeout, - scroll=scroll or state in {"actionable", "scrollable"}, - stable=requires_stable, - receives_events=state == "actionable", - stable_position_required=stable_position_required, - action_position=action_position, - ) + try: + last_info = self._target_state( + command_timeout, + scroll=scroll or state in {"actionable", "scrollable"}, + stable=requires_stable, + receives_events=state == "actionable", + stable_position_required=stable_position_required, + action_position=action_position, + ) + except TimeoutError: + # A single actionability probe that exceeds its own budget is transient, + # not fatal: over a high-latency remote-CDP transport one probe's round + # trips can outlast a small fixed cap. Keep polling until the outer + # deadline instead of aborting the whole action (matches Playwright). + if time.monotonic() >= deadline: + break + _sleep_until_next_poll(deadline) + continue if self._strict and not self._explicit_index: self._raise_frame_strict_violation(last_info.get("frame_strict_violation")) count = int(last_info.get("count") or 0) @@ -21857,9 +21880,17 @@ def _wait_for_fill_ready(self, action: str, *, timeout: Optional[float] = None) while True: _raise_if_owner_unavailable(self._page, method=method) remaining_ms = max((deadline - time.monotonic()) * 1000, 1.0) - command_timeout = min(max(timeout_ms, 1.0), remaining_ms, 1_000.0) + command_timeout = _actionability_probe_timeout(remaining_ms) self._page._run_locator_handlers(deadline) - last_info = self._target_state(command_timeout) + try: + last_info = self._target_state(command_timeout) + except TimeoutError: + # A single fill-readiness probe timing out is transient over a slow + # remote-CDP transport: keep polling until the outer deadline. + if time.monotonic() >= deadline: + break + _sleep_until_next_poll(deadline) + continue if self._strict and not self._explicit_index: self._raise_frame_strict_violation(last_info.get("frame_strict_violation")) count = int(last_info.get("count") or 0) @@ -23614,7 +23645,7 @@ def _fill(self, value: str, *, action: str, timeout: Optional[float] = None, for last_info: dict[str, Any] = {} while True: remaining_ms = max((deadline - time.monotonic()) * 1000, 1.0) - command_timeout = min(max(timeout_ms, 1.0), remaining_ms, 1_000.0) + command_timeout = _actionability_probe_timeout(remaining_ms) self._page._run_locator_handlers(deadline) try: result = self._eval(fill_script, command_timeout) @@ -24397,7 +24428,7 @@ def _select_option_impl( deadline = started + max(timeout_ms, 0.0) / 1000 while True: remaining_ms = max((deadline - time.monotonic()) * 1000, 1.0) - command_timeout = min(max(timeout_ms, 1.0), remaining_ms, 1_000.0) + command_timeout = _actionability_probe_timeout(remaining_ms) try: result = self._eval(select_script, command_timeout, method=method) except TimeoutError: diff --git a/src/lib.rs b/src/lib.rs index fdc2509..85f3465 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4174,27 +4174,6 @@ fn resolve_cached_main_frame_url( } impl PageInner { - async fn main_frame_id( - &self, - client: &CdpClient, - session_id: &str, - timeout: Duration, - ) -> RwResult { - if let Some(frame_id) = self.main_frame_id.lock().unwrap().clone() { - return Ok(frame_id); - } - let frame_tree = client - .send("Page.getFrameTree", json!({}), Some(session_id), timeout) - .await?; - let frame_id = frame_tree - .pointer("/frameTree/frame/id") - .and_then(Value::as_str) - .ok_or_else(|| RwError::Message("CDP did not return a main frame id".to_string()))? - .to_string(); - *self.main_frame_id.lock().unwrap() = Some(frame_id.clone()); - Ok(frame_id) - } - fn session_for_frame_id(&self, frame_id: &str) -> String { self.frame_state .lock() @@ -8096,14 +8075,25 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; let session_id = page.session_id.clone(); browser .block_on(async move { - let frame_id = page.main_frame_id(&client, &session_id, timeout).await?; - let frame_id_json = serde_json::to_string(&frame_id)?; let html_json = serde_json::to_string(&html)?; - let params_json = format!("{{\"frameId\":{frame_id_json},\"html\":{html_json}}}"); + // Materialize the document via document.open/write/close through + // Runtime.evaluate rather than the Page.setDocumentContent command. + // Page.setDocumentContent deterministically hangs on + // connect_over_cdp-attached pages (the browser never returns the command + // result, so the call blocks until timeout), while Runtime.evaluate is + // reliable over the same remote-CDP transport. This mirrors how Playwright + // sets content, and delivers the byte-identical document. + let materialize = format!( + "(() => {{ try {{ window.stop(); }} catch (e) {{}} document.open(); document.write({html_json}); document.close(); }})()" + ); client - .send_raw_params_json( - "Page.setDocumentContent", - params_json, + .send( + "Runtime.evaluate", + json!({ + "expression": materialize, + "awaitPromise": false, + "returnByValue": true, + }), Some(&session_id), timeout, ) diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 940d1b8..d40d72f 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -2083,6 +2083,49 @@ def test_chromium_connect_over_cdp_adopts_default_context_pages_and_disconnects_ launched.close() +def test_connect_over_cdp_set_content_on_adopted_and_new_pages(playwright): + # Regression: Page.set_content() over a connect_over_cdp attach must materialize the + # document without relying on the Page.setDocumentContent command, which hangs on + # remote-CDP-attached pages (the browser never returns the command result, so the call + # blocks until timeout on both reused and freshly-created pages). set_content now uses + # document.open/write/close through Runtime.evaluate, matching Playwright, and delivers + # the byte-identical document over the same transport. + launched = playwright.chromium.launch(headless=True) + connected = None + try: + origin = launched.new_page() + origin.goto(data_url("Origin

origin

")) + + connected = playwright.chromium.connect_over_cdp(launched._ws_endpoint) + context = connected.contexts[0] + + html = ( + "Adopted SC" + "

Header

" + "" + "" + "" + ) + + # Reused/adopted page (probe c1 path) with wait_until="load". + adopted = next(page for page in context.pages if page.url == origin.url) + adopted.set_content(html, wait_until="load", timeout=5_000) + assert adopted.title() == "Adopted SC" + assert adopted.locator("#hdr").text_content() == "Header" + assert adopted.select_option("#sel", "y") == ["y"] + assert adopted.frame_locator("#frm").locator("#m").text_content() == "frame-hello" + + # Freshly created page (probe c3 path) with wait_until="domcontentloaded". + fresh = context.new_page() + fresh.set_content(html, wait_until="domcontentloaded", timeout=5_000) + assert fresh.title() == "Adopted SC" + assert fresh.frame_locator("#frm").locator("#m").text_content() == "frame-hello" + finally: + if connected is not None: + connected.close() + launched.close() + + def test_connect_over_cdp_context_request_syncs_remote_cookies_for_download(playwright, http_server): launched = playwright.chromium.launch(headless=True) connected = None @@ -10177,6 +10220,41 @@ def test_locator_click_waits_for_delayed_element(page): assert page.evaluate("document.body.dataset.clicked") == "yes" +def test_actionability_probe_tolerates_slow_remote_cdp_round_trips(page, monkeypatch): + # Regression for remote-CDP interaction timeouts (connect_over_cdp attach): over a + # high-latency transport a single actionability probe's CDP round-trips can outlast the + # per-probe budget. The action must keep polling until the outer deadline instead of + # aborting on that transient probe timeout. Before the fix, click()/select_option() + # failed in ~1s with a raw "timed out after 1000 ms" (the old fixed per-probe cap) even + # though the outer timeout was 30s, so 13/60 remote-CDP interactions timed out. + from rustwright.sync_api import Locator + + page.set_content( + "" + "" + "before" + ) + + real_target_state = Locator._target_state + probe_calls = {"count": 0} + + def slow_probe_target_state(self, timeout=None, **kwargs): + probe_calls["count"] += 1 + # Model a probe whose round-trips need more than the old 1000ms cap to complete. + if timeout is not None and timeout < 1_500.0: + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_target_state(self, timeout, **kwargs) + + monkeypatch.setattr(Locator, "_target_state", slow_probe_target_state) + + # With the outer timeout at 30s, each probe must be granted well over 1500ms, so the + # interaction completes rather than dying on the first under-budgeted probe. + page.click("#mutate", timeout=30_000) + assert page.locator("#state").text_content() == "after" + assert page.select_option("#sel", "b", timeout=30_000) == ["b"] + assert probe_calls["count"] > 0 + + def test_locator_fill_waits_for_delayed_editable(page): page.set_content( """ From b5ac2f3579899f2a25ad54fe2640e19900c9d7ec Mon Sep 17 00:00:00 2001 From: LawyZheng <26927551+LawyZheng@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:47:14 +0800 Subject: [PATCH 2/3] Fix remote attach lifecycle waits --- python/rustwright/sync_api.py | 44 ++++++++++++++++++++++++++++--- tests/test_rustwright_sync_api.py | 35 ++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 3872afa..77d959c 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -15090,6 +15090,11 @@ def goto( self._uses_single_process_fallback() and target_url.lower().startswith("chrome://crash") ) try: + lifecycle_timeout = navigation_timeout if navigation_timeout > 0 else 24 * 60 * 60 * 1000 + deadline = time.monotonic() + (lifecycle_timeout / 1000) + lifecycle_waiter = ( + None if normalized_state == "commit" else self._document_lifecycle_waiter(normalized_state) + ) self._retain_navigation_response_bodies() self._mark_navigation_history_boundary() self._set_content_html_document_known = None @@ -15105,7 +15110,7 @@ def goto( "Page.goto", self._core.goto, target_url, - normalized_state, + "commit", navigation_timeout, normalized_referer, )) @@ -15144,6 +15149,14 @@ def goto( self._trace_end_action(call_id, result={"response": {"url": response.url, "status": response.status}}) return self._remember_navigation_response(response) raise + if normalized_state != "commit": + remaining = max(1.0, (deadline - time.monotonic()) * 1000) + self._wait_for_document_state( + normalized_state, + timeout=remaining, + timeout_label="navigation", + lifecycle_waiter=lifecycle_waiter, + ) if payload is None or target_url.lower().startswith(("about:", "data:")): if self._context is not None: self._context._apply_storage_state_to_page(self) @@ -15222,11 +15235,20 @@ def _wait_for_document_state( timeout: float, prior_time_origin: Any = None, timeout_label: str = "document", + lifecycle_waiter: Any = None, ) -> None: state = str(wait_until or "load") if timeout <= 0: timeout = 24 * 60 * 60 * 1000 deadline = time.monotonic() + (timeout / 1000) + if lifecycle_waiter is not None: + try: + _call(lifecycle_waiter.wait, min(10.0, timeout)) + if state == "networkidle": + self._wait_for_network_idle(deadline, timeout_label=timeout_label) + return + except TimeoutError: + pass while time.monotonic() < deadline: remaining = max(1.0, (deadline - time.monotonic()) * 1000) try: @@ -15239,7 +15261,7 @@ def _wait_for_document_state( && Array.from(document.querySelectorAll('link[rel~="stylesheet"]')).every((link) => !!link.sheet) })""", None, - min(250.0, remaining), + remaining, ))) except Error: time.sleep(0.02) @@ -15262,6 +15284,14 @@ def _wait_for_document_state( time.sleep(0.02) raise TimeoutError(f"timed out waiting for {timeout_label} {state}") + def _document_lifecycle_waiter(self, state: str) -> Any: + method = ( + "Page.domContentEventFired" + if state == "domcontentloaded" + else "Page.loadEventFired" + ) + return _call(self._core.cdp_session).event_waiter(method) + def _network_idle_request_id(self, request: Request) -> Optional[str]: request_id = getattr(request, "_request_id", None) if not request_id: @@ -16595,6 +16625,9 @@ def set_content( call_id = self._trace_begin_action("setContent", trace_params) try: deadline = time.monotonic() + (timeout_ms / 1000) + lifecycle_waiter = ( + None if normalized_state == "commit" else self._document_lifecycle_waiter(normalized_state) + ) content_type = None if self._set_content_html_document_known is True: content_type = "text/html" @@ -16633,7 +16666,12 @@ def set_content( if normalized_state != "commit": remaining = max(1.0, (deadline - time.monotonic()) * 1000) try: - self._wait_for_document_state(normalized_state, timeout=remaining, timeout_label="set_content") + self._wait_for_document_state( + normalized_state, + timeout=remaining, + timeout_label="set_content", + lifecycle_waiter=lifecycle_waiter, + ) except TimeoutError: raise _method_timeout_error("Page.set_content", timeout_ms) from None self._slow_mo() diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index d40d72f..f465048 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -2115,17 +2115,48 @@ def test_connect_over_cdp_set_content_on_adopted_and_new_pages(playwright): assert adopted.select_option("#sel", "y") == ["y"] assert adopted.frame_locator("#frm").locator("#m").text_content() == "frame-hello" - # Freshly created page (probe c3 path) with wait_until="domcontentloaded". + # Reused page with the weaker lifecycle state (probe c2 path). + adopted.set_content(html, wait_until="domcontentloaded", timeout=5_000) + assert adopted.title() == "Adopted SC" + + # Freshly created page with wait_until="load" (probe c3 path). fresh = context.new_page() - fresh.set_content(html, wait_until="domcontentloaded", timeout=5_000) + fresh.set_content(html, wait_until="load", timeout=5_000) assert fresh.title() == "Adopted SC" assert fresh.frame_locator("#frm").locator("#m").text_content() == "frame-hello" + + # Deterministic data-URL navigation on the reused page (probe c4 shape). + adopted.goto(data_url(html), wait_until="load", timeout=5_000) + assert adopted.title() == "Adopted SC" + assert adopted.locator("#hdr").text_content() == "Header" finally: if connected is not None: connected.close() launched.close() +def test_document_state_fallback_uses_remaining_budget_for_high_latency_attach(): + from rustwright.sync_api import Page + + class HighLatencyCore: + def __init__(self): + self.timeouts = [] + + def evaluate(self, expression, arg, timeout): + self.timeouts.append(timeout) + if timeout < 300: + raise TimeoutError("remote CDP round trip exceeded command slice") + return json.dumps({"readyState": "complete", "resourcesLoaded": True}) + + owner = Page.__new__(Page) + owner._core = HighLatencyCore() + + owner._wait_for_document_state("load", timeout=700) + + assert len(owner._core.timeouts) == 1 + assert owner._core.timeouts[0] > 600 + + def test_connect_over_cdp_context_request_syncs_remote_cookies_for_download(playwright, http_server): launched = playwright.chromium.launch(headless=True) connected = None From 9bbe8861a8e3fa38a4e9be83f8fd6975e17401c5 Mon Sep 17 00:00:00 2001 From: LawyZheng <26927551+LawyZheng@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:00:33 +0800 Subject: [PATCH 3/3] Materialize set_content via Runtime.evaluate on the real text/html attach path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the remaining remote-CDP set_content/goto hang was the post-materialization lifecycle wait, not the materialization itself: - Page.set_content routed the actual text/html branch through a lifecycle wait that first peeked for a Page.loadEventFired/domContentEventFired CDP event. On a connect_over_cdp-attached page, document.write() on an already-loaded document does not re-fire those lifecycle events, so the event wait can never complete; combined with the per-sub-call timeout budgets introduced in the prior change, the operation stopped honoring a single caller deadline and overran (bounded 20s timeout regressed to a hard isolation kill). Fix: - Materialize every document (including text/html) via Runtime.evaluate(window.stop(); document.open/write/close) — the mechanism the remote-CDP attach probe proves reliable — instead of the native command path for the materialize-and-wait flow. Stealth defaults re-apply automatically via the registered addScriptToEvaluateOnNewDocument script when document.write() creates the new document. HTML is transported as a JSON-encoded evaluate argument, preserving escaping. - Remove the unreliable Page lifecycle-event waiter from _wait_for_document_state, Page.goto, and Page.set_content, and delete the now-unused _document_lifecycle_waiter helper. Readiness is confirmed by polling document.readyState with the full remaining budget per poll (the one independently-justified improvement from the prior change), and the whole operation is bounded by a single caller deadline. - goto keeps the commit + readyState-poll structure required for attached pages; the native command fast path is retained only for wait_until="commit". TDD: test_set_content_text_html_materializes_via_evaluate_not_native_command proves the text/html branch materializes via Runtime.evaluate(document.write), never calls the native command, creates no lifecycle-event waiter, and bounds the materialize timeout by the single caller deadline. RED before this change, GREEN after. The Anchor remote-attach hang is not reproducible in this host (local Chromium launch is blocked), so external run3 against the private Tier-A attach probe remains the gate for Defect A. Co-Authored-By: Claude Opus 4.8 --- python/rustwright/sync_api.py | 74 +++++++++++++---------------- tests/test_rustwright_sync_api.py | 78 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 42 deletions(-) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 77d959c..77219cc 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -15092,9 +15092,6 @@ def goto( try: lifecycle_timeout = navigation_timeout if navigation_timeout > 0 else 24 * 60 * 60 * 1000 deadline = time.monotonic() + (lifecycle_timeout / 1000) - lifecycle_waiter = ( - None if normalized_state == "commit" else self._document_lifecycle_waiter(normalized_state) - ) self._retain_navigation_response_bodies() self._mark_navigation_history_boundary() self._set_content_html_document_known = None @@ -15155,7 +15152,6 @@ def goto( normalized_state, timeout=remaining, timeout_label="navigation", - lifecycle_waiter=lifecycle_waiter, ) if payload is None or target_url.lower().startswith(("about:", "data:")): if self._context is not None: @@ -15235,20 +15231,17 @@ def _wait_for_document_state( timeout: float, prior_time_origin: Any = None, timeout_label: str = "document", - lifecycle_waiter: Any = None, ) -> None: + # Readiness on remote-CDP-attached pages is observed by polling document.readyState via + # Runtime.evaluate. The Page lifecycle events (Page.loadEventFired/domContentEventFired) + # are unreliable here: after set_content's document.write() on an already-loaded page they + # never re-fire, so an event wait would hang. Each poll gets the full remaining budget + # (a high-latency remote-CDP round trip can exceed a fixed short slice), and the single + # caller deadline bounds the whole wait. state = str(wait_until or "load") if timeout <= 0: timeout = 24 * 60 * 60 * 1000 deadline = time.monotonic() + (timeout / 1000) - if lifecycle_waiter is not None: - try: - _call(lifecycle_waiter.wait, min(10.0, timeout)) - if state == "networkidle": - self._wait_for_network_idle(deadline, timeout_label=timeout_label) - return - except TimeoutError: - pass while time.monotonic() < deadline: remaining = max(1.0, (deadline - time.monotonic()) * 1000) try: @@ -15284,14 +15277,6 @@ def _wait_for_document_state( time.sleep(0.02) raise TimeoutError(f"timed out waiting for {timeout_label} {state}") - def _document_lifecycle_waiter(self, state: str) -> Any: - method = ( - "Page.domContentEventFired" - if state == "domcontentloaded" - else "Page.loadEventFired" - ) - return _call(self._core.cdp_session).event_waiter(method) - def _network_idle_request_id(self, request: Request) -> Optional[str]: request_id = getattr(request, "_request_id", None) if not request_id: @@ -16625,9 +16610,6 @@ def set_content( call_id = self._trace_begin_action("setContent", trace_params) try: deadline = time.monotonic() + (timeout_ms / 1000) - lifecycle_waiter = ( - None if normalized_state == "commit" else self._document_lifecycle_waiter(normalized_state) - ) content_type = None if self._set_content_html_document_known is True: content_type = "text/html" @@ -16637,7 +16619,7 @@ def set_content( self._core.evaluate, "() => document.contentType", None, - timeout_ms, + max(1.0, (deadline - time.monotonic()) * 1000), ) content_type = _decode_json_result(json.loads(content_type_result)) except Exception: @@ -16647,22 +16629,31 @@ def set_content( "text/html", "application/xhtml+xml", } - if isinstance(content_type, str) and content_type.lower() not in {"text/html", "application/xhtml+xml"}: - _call_with_method_prefix( - "Page.set_content", - self._core.evaluate, - """html => { - document.open(); - document.write(html); - document.close(); - }""", - json.dumps(html), - timeout_ms, - ) - self._set_content_html_document_known = False - else: - _call_wait_with_playwright_timeout("Page.set_content", self._core.set_content, html, timeout_ms) - self._set_content_html_document_known = True + # Materialize the document via document.open/write/close through Runtime.evaluate for + # every content type, including the real text/html attach path. This is the mechanism + # the remote-CDP attach probe proves reliable (case c5); the native command path is not + # used for the materialize-and-wait flow because its post-write lifecycle observation + # hangs on connect_over_cdp-attached pages. window.stop() halts any in-flight load first, + # matching Playwright. Stealth defaults re-apply automatically via the registered + # addScriptToEvaluateOnNewDocument script when document.write() creates the new document, + # so no separate injection is needed. The HTML is transported as a JSON-encoded evaluate + # argument, preserving escaping, and every step is bounded by the single caller deadline. + _call_with_method_prefix( + "Page.set_content", + self._core.evaluate, + """html => { + try { window.stop(); } catch (error) {} + document.open(); + document.write(html); + document.close(); + }""", + json.dumps(html), + max(1.0, (deadline - time.monotonic()) * 1000), + ) + self._set_content_html_document_known = not ( + isinstance(content_type, str) + and content_type.lower() not in {"text/html", "application/xhtml+xml"} + ) if normalized_state != "commit": remaining = max(1.0, (deadline - time.monotonic()) * 1000) try: @@ -16670,7 +16661,6 @@ def set_content( normalized_state, timeout=remaining, timeout_label="set_content", - lifecycle_waiter=lifecycle_waiter, ) except TimeoutError: raise _method_timeout_error("Page.set_content", timeout_ms) from None diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index f465048..37e680a 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -2157,6 +2157,84 @@ def evaluate(self, expression, arg, timeout): assert owner._core.timeouts[0] > 600 +def test_set_content_text_html_materializes_via_evaluate_not_native_command(): + # Regression (RUS-5, third cycle): on a remote-CDP *attached* text/html page, Page.set_content + # must materialize the document via Runtime.evaluate(document.open/write/close) — the mechanism + # the attach probe proves is reliable (case c5) — and must NOT route the real text/html branch + # through the native command path nor stack an unbounded lifecycle-event wait. After + # document.write() on an already-loaded page the load/domcontentloaded CDP lifecycle events do + # not re-fire, so readiness is confirmed by polling document.readyState, and the whole operation + # is bounded by a single caller deadline. + from rustwright.sync_api import Page + + class RecordingCore: + def __init__(self): + self.evaluate_calls = [] + self.set_content_calls = [] + self.event_waiters = 0 + + def evaluate(self, expression, arg, timeout): + self.evaluate_calls.append((expression, arg, timeout)) + if "document.contentType" in expression: + return json.dumps("text/html") + if "document.write" in expression: + return json.dumps(None) + if "readyState" in expression: + return json.dumps({"readyState": "complete", "resourcesLoaded": True}) + return json.dumps(None) + + def set_content(self, html, timeout): + self.set_content_calls.append((html, timeout)) + + def cdp_session(self): + outer = self + + class _Waiter: + def wait(self, timeout): + raise TimeoutError("no lifecycle event refires after document.write on attach") + + class _Session: + def event_waiter(self, method): + outer.event_waiters += 1 + return _Waiter() + + return _Session() + + html = ( + "Attached" + "

Header

" + ) + + owner = Page.__new__(Page) + owner._core = RecordingCore() + owner._context = None + owner._slow_mo_ms = 0 + owner._set_content_html_document_known = None + owner._mark_request_cookie_sync_required = lambda: None + owner._trace_begin_action = lambda *args, **kwargs: None + owner._trace_end_action = lambda *args, **kwargs: None + owner._slow_mo = lambda: None + + owner.set_content(html, wait_until="load", timeout=5_000) + + # The text/html attach path must not hang on the native command; it materializes via evaluate. + assert owner._core.set_content_calls == [] + materialize = [call for call in owner._core.evaluate_calls if "document.write" in call[0]] + assert len(materialize) == 1, owner._core.evaluate_calls + expression, arg, materialize_timeout = materialize[0] + assert "document.open()" in expression + assert "document.close()" in expression + # HTML is transported as a JSON-encoded evaluate argument (escaping preserved), not interpolated. + assert json.loads(arg) == html + # A single caller deadline bounds the whole operation: the materialize evaluate receives a + # remaining-bounded timeout, never a fresh full budget stacked on top of the content-type probe. + assert materialize_timeout <= 5_000 + # The unreliable Page lifecycle-event waiter is gone (it never re-fires after document.write). + assert owner._core.event_waiters == 0 + # The document was materialized (evaluate) and readiness confirmed via the readyState poll. + assert any("readyState" in call[0] for call in owner._core.evaluate_calls) + + def test_connect_over_cdp_context_request_syncs_remote_cookies_for_download(playwright, http_server): launched = playwright.chromium.launch(headless=True) connected = None