diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py
index ccc7102..77219cc 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)
@@ -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
@@ -15092,7 +15107,7 @@ def goto(
"Page.goto",
self._core.goto,
target_url,
- normalized_state,
+ "commit",
navigation_timeout,
normalized_referer,
))
@@ -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)
@@ -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
@@ -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)
@@ -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:
@@ -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()
@@ -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)
@@ -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)
@@ -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)
@@ -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:
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,
- ) -> RwResultorigin
"))
+
+ connected = playwright.chromium.connect_over_cdp(launched._ws_endpoint)
+ context = connected.contexts[0]
+
+ html = (
+ "Header
"
+ ""
+ "