Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 92 additions & 33 deletions python/rustwright/sync_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -15077,6 +15090,8 @@ 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)
self._retain_navigation_response_bodies()
self._mark_navigation_history_boundary()
self._set_content_html_document_known = None
Expand All @@ -15092,7 +15107,7 @@ def goto(
"Page.goto",
self._core.goto,
target_url,
normalized_state,
"commit",
navigation_timeout,
normalized_referer,
))
Expand Down Expand Up @@ -15131,6 +15146,13 @@ 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",
)
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)
Expand Down Expand Up @@ -15210,6 +15232,12 @@ def _wait_for_document_state(
prior_time_origin: Any = None,
timeout_label: str = "document",
) -> 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
Expand All @@ -15226,7 +15254,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)
Expand Down Expand Up @@ -16591,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:
Expand All @@ -16601,26 +16629,39 @@ 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:
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",
)
except TimeoutError:
raise _method_timeout_error("Page.set_content", timeout_ms) from None
self._slow_mo()
Expand Down Expand Up @@ -21785,16 +21826,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)
Expand Down Expand Up @@ -21857,9 +21908,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)
Expand Down Expand Up @@ -23614,7 +23673,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)
Expand Down Expand Up @@ -24397,7 +24456,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:
Expand Down
44 changes: 17 additions & 27 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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()
Expand Down Expand Up @@ -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,
)
Expand Down
Loading
Loading