From 72a5dbb4a7daa51e2772b7b231a063fbcb5d9553 Mon Sep 17 00:00:00 2001 From: LawyZheng <26927551+LawyZheng@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:18:36 +0800 Subject: [PATCH 1/3] Give remote-CDP actionability probes the full remaining action budget Locator actionability/state polling loops capped each individual 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 transport (e.g. connect_over_cdp attach) those can 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 action deadline was still far off. Budget each probe with the full remaining action time via a new _actionability_probe_timeout helper, and treat a per-probe TimeoutError as a transient retry until the real deadline (matching Playwright). Applied uniformly to the four sibling loops: _wait_for_single, _wait_for_fill_ready, the fill-apply loop, and the select-apply loop. Polling cadence is still driven by _sleep_until_next_poll, so widening the per-probe bound does not busy-loop. For an infinite wait the probe keeps a bounded 1000 ms cadence so owner-availability and locator handlers are still re-checked periodically. Add regression test test_actionability_probe_tolerates_slow_remote_cdp_round_trips: a probe modeled to need more than the old 1000 ms cap fails click()/select_option() under the former fixed cap and passes once each probe receives the full budget. Co-Authored-By: Claude Opus 4.8 --- python/rustwright/sync_api.py | 57 ++++++++++++++++++++++++------- tests/test_rustwright_sync_api.py | 35 +++++++++++++++++++ 2 files changed, 79 insertions(+), 13 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/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 940d1b8..31e661f 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -10177,6 +10177,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 9aa97878422e30c6cc8b24e5d3f9796ec99536cc Mon Sep 17 00:00:00 2001 From: LawyZheng <26927551+LawyZheng@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:14:57 +0800 Subject: [PATCH 2/3] Retry transient probe timeouts in the fill/select-apply loops The fill-apply loop caught only the generic Error and re-raised a per-probe TimeoutError, so a single actionability/state probe whose CDP round trips outlast the per-probe budget over a high-latency remote transport aborted the whole fill/type even though the outer action deadline was still far off. This was inconsistent with the sibling wait loops (_wait_for_single, _wait_for_fill_ready) and the select-apply loop, which already treat a probe TimeoutError as transient. Catch TimeoutError before the generic Error handler in the fill-apply loop: surface owner unavailability immediately, otherwise keep polling with the existing _sleep_until_next_poll cadence until the outer deadline, then fall through to the loop's normal editable-timeout error. TimeoutError subclasses Error, so the new handler is ordered ahead of it. Add the same owner-availability check to the select-apply loop's existing TimeoutError handler so all four sibling loops preserve close/crash/owner-unavailable propagation uniformly. No timeout is inflated and cadence is unchanged, so this does not busy-loop. Replace the regression coverage: the prior test never triggered the retry path (it only asserted the enlarged per-probe budget). Add call-count-driven tests that force a first-probe TimeoutError on the real affected loops -- fill-apply retry, select-apply retry, _wait_for_single retry -- plus fill-apply outer-deadline behavior and owner-crash propagation on a probe timeout. Reword the test comment to drop the raw benchmark-like interaction ratio. Co-Authored-By: Claude Opus 4.8 --- python/rustwright/sync_api.py | 15 +++ tests/test_rustwright_sync_api.py | 146 +++++++++++++++++++++++++----- 2 files changed, 138 insertions(+), 23 deletions(-) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 3872afa..8d402f1 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -23649,6 +23649,18 @@ def _fill(self, value: str, *, action: str, timeout: Optional[float] = None, for self._page._run_locator_handlers(deadline) try: result = self._eval(fill_script, command_timeout) + except TimeoutError: + # A single fill-apply probe timing out is transient over a slow remote-CDP + # transport: one probe's CDP round trips can outlast the per-probe budget. + # Surface owner unavailability immediately, otherwise keep polling until the + # outer deadline instead of aborting the whole action (matches Playwright and + # the sibling wait/select loops). TimeoutError subclasses Error, so this must + # precede the generic Error handler below. + _raise_if_owner_unavailable(self._page, method=_locator_method_for_action(action)) + if time.monotonic() >= deadline: + break + _sleep_until_next_poll(deadline) + continue except Error as exc: if "No element matches locator" not in str(exc): raise @@ -24432,6 +24444,9 @@ def _select_option_impl( try: result = self._eval(select_script, command_timeout, method=method) except TimeoutError: + # A transient select-apply probe timeout is retried until the outer deadline + # (see the fill-apply loop), but owner unavailability is surfaced immediately. + _raise_if_owner_unavailable(self._page, method=method) if time.monotonic() >= deadline: break _sleep_until_next_poll(deadline) diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 31e661f..85dab3c 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -10177,39 +10177,139 @@ 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. +def test_wait_for_single_retries_transient_actionability_probe_timeout(page, monkeypatch): + # A single actionability probe can exceed its own budget over a high-latency remote-CDP + # transport (connect_over_cdp attach): its several CDP round trips outlast the per-probe + # budget even though the outer action deadline is still far off. The wait loop must treat + # that probe TimeoutError as transient and keep polling, not abort the whole action. + from rustwright.sync_api import Locator + + page.set_content("") + + real_target_state = Locator._target_state + probe = {"calls": 0, "timed_out": 0} + + def flaky_target_state(self, timeout=None, **kwargs): + probe["calls"] += 1 + # Time out only the first probe regardless of how large its budget is, so the retry + # path itself is exercised rather than merely the enlarged per-probe budget. + if probe["timed_out"] == 0: + probe["timed_out"] += 1 + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_target_state(self, timeout, **kwargs) + + monkeypatch.setattr(Locator, "_target_state", flaky_target_state) + + page.click("#go", timeout=30_000) + + assert page.evaluate("document.body.dataset.clicked") == "yes" + assert probe["timed_out"] == 1 + assert probe["calls"] >= 2 # retried after the transient probe timeout + + +def test_fill_apply_loop_retries_transient_probe_timeout(page, monkeypatch): + # The fill-apply loop performs the actual fill via _eval. A first probe whose CDP round + # trips outlast the per-probe budget raises TimeoutError; the loop must retry until the + # outer deadline instead of re-raising that transient timeout as a fatal error. + from rustwright.sync_api import Locator + + page.set_content("") + + real_eval = Locator._eval + probe = {"calls": 0, "timed_out": 0} + + def flaky_eval(self, body, timeout=None, *, method=None): + if "nonFillableInputTypes" in body: + probe["calls"] += 1 + if probe["timed_out"] == 0: + probe["timed_out"] += 1 + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_eval(self, body, timeout, method=method) + + monkeypatch.setattr(Locator, "_eval", flaky_eval) + + page.fill("#name", "Ada", timeout=30_000) + + assert page.locator("#name").input_value() == "Ada" + assert probe["timed_out"] == 1 + assert probe["calls"] >= 2 # retried after the transient probe timeout + + +def test_select_apply_loop_retries_transient_probe_timeout(page, monkeypatch): + # The select-apply loop performs the final selection via _eval. A first transient probe + # timeout must be retried until the outer deadline rather than aborting select_option. from rustwright.sync_api import Locator page.set_content( "" - "" - "before" ) - real_target_state = Locator._target_state - probe_calls = {"count": 0} + real_eval = Locator._eval + probe = {"calls": 0, "timed_out": 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) + def flaky_eval(self, body, timeout=None, *, method=None): + if "foundValues" in body: + probe["calls"] += 1 + if probe["timed_out"] == 0: + probe["timed_out"] += 1 + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_eval(self, body, timeout, method=method) - monkeypatch.setattr(Locator, "_target_state", slow_probe_target_state) + monkeypatch.setattr(Locator, "_eval", flaky_eval) - # 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 + assert probe["timed_out"] == 1 + assert probe["calls"] >= 2 # retried after the transient probe timeout + + +def test_fill_apply_loop_surfaces_outer_deadline_on_persistent_probe_timeout(page, monkeypatch): + # When every fill-apply probe keeps timing out, the loop must still end at the outer + # deadline with its own editable-timeout message (not the raw per-probe timeout) and + # without busy-looping past the deadline. + from rustwright.sync_api import Locator + + page.set_content("") + + real_eval = Locator._eval + + def always_timeout_eval(self, body, timeout=None, *, method=None): + if "nonFillableInputTypes" in body: + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_eval(self, body, timeout, method=method) + + monkeypatch.setattr(Locator, "_eval", always_timeout_eval) + + started = time.monotonic() + with pytest.raises(TimeoutError, match="editable"): + page.fill("#name", "Ada", timeout=300) + elapsed = time.monotonic() - started + + assert elapsed < 5 # honored the ~300ms outer deadline; no runaway busy-loop + + +def test_fill_apply_loop_propagates_owner_crash_on_probe_timeout(page, monkeypatch): + # A probe timeout must not mask owner unavailability: if the page crashes while a fill + # probe is timing out, the loop surfaces the crash immediately instead of polling until + # the outer deadline. + from rustwright.sync_api import Locator + + page.set_content("") + + real_eval = Locator._eval + + def crashing_probe_eval(self, body, timeout=None, *, method=None): + if "nonFillableInputTypes" in body: + self._page._crashed = True + raise TimeoutError(f"timed out after {int(timeout)} ms") + return real_eval(self, body, timeout, method=method) + + monkeypatch.setattr(Locator, "_eval", crashing_probe_eval) + + try: + with pytest.raises(Error, match="Page crashed"): + page.fill("#name", "Ada", timeout=30_000) + finally: + page._crashed = False def test_locator_fill_waits_for_delayed_editable(page): From 6ecb956be08262651835ed530c79c63f8be96569 Mon Sep 17 00:00:00 2001 From: LawyZheng <26927551+LawyZheng@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:26:11 +0800 Subject: [PATCH 3/3] Release the GIL explicitly in the locator_eval binding The native `locator_eval` binding called `evaluate_locator` directly while its siblings `click` and `locator_eval_handle` wrap the same call in `py.detach`. Functionally the GIL was already released one layer deeper (`evaluate_locator` -> `block_on` -> `run_blocking_detached` -> `run_detached` runs the blocking CDP round-trip under `Python::try_attach(|py| py.detach(..))`), so `locator_eval` never actually monopolized the GIL. But the binding-layer inconsistency is worth closing: make the detach explicit so all three locator bindings match and a future refactor that adds GIL-holding work before `block_on` (or changes the transport) cannot silently regress concurrency. Add an invariant guard test proving a blocked native `locator_eval` does not freeze other Python threads (a pure-Python busy thread keeps advancing while the native call waits ~750ms). Also align `_wait_for_single`/`_wait_for_fill_ready` with the fill/select-apply loops: surface owner unavailability inside `except TimeoutError` before the deadline break, so a page/context/browser that closes exactly as a probe times out at the deadline raises the precise owner error instead of a generic actionability timeout. Covered by a focused RED->GREEN test on the live `_wait_for_single` path (`_wait_for_fill_ready` is currently unreferenced, so the mirrored edit is consistency-only). No transport or outer/probe timeout semantics changed. Co-Authored-By: Claude Opus 4.8 --- python/rustwright/sync_api.py | 16 ++++-- src/lib.rs | 8 ++- tests/test_rustwright_sync_api.py | 82 +++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 8d402f1..f4f7330 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -21812,8 +21812,13 @@ def _wait_for_single( 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). + # trips can outlast a small fixed cap. Surface owner unavailability + # immediately, otherwise keep polling until the outer deadline instead of + # aborting the whole action (matches Playwright and the fill/select-apply + # loops). Doing this before the deadline check means a page/context/browser + # that closed mid-probe raises the precise owner error rather than a + # generic timeout when the deadline coincides. + _raise_if_owner_unavailable(self._page, method=method) if time.monotonic() >= deadline: break _sleep_until_next_poll(deadline) @@ -21886,7 +21891,12 @@ def _wait_for_fill_ready(self, action: str, *, timeout: Optional[float] = None) 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. + # remote-CDP transport: surface owner unavailability immediately, otherwise + # keep polling until the outer deadline (matches the fill/select-apply + # loops). Checking the owner before the deadline break means a closed + # page/context/browser raises the precise owner error rather than a generic + # timeout when the deadline coincides. + _raise_if_owner_unavailable(self._page, method=method) if time.monotonic() >= deadline: break _sleep_until_next_poll(deadline) diff --git a/src/lib.rs b/src/lib.rs index fdc2509..46b1583 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9046,12 +9046,18 @@ return true; #[pyo3(signature = (locator_json, index, body, timeout_ms=None))] fn locator_eval( &self, + py: Python<'_>, locator_json: &str, index: usize, body: &str, timeout_ms: Option, ) -> PyResult { - self.evaluate_locator(locator_json, index, body, timeout_ms) + // Release the GIL for the duration of the blocking CDP round-trip, matching + // `click` and `locator_eval_handle`. `evaluate_locator` already detaches inside + // `block_on`, but keeping the detach explicit at the binding layer keeps the + // three locator bindings consistent and guards against a future refactor that + // adds GIL-holding work before `block_on` or changes the transport. + py.detach(|| self.evaluate_locator(locator_json, index, body, timeout_ms)) .map_err(py_err) } diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 85dab3c..d6cc041 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -10312,6 +10312,32 @@ def crashing_probe_eval(self, body, timeout=None, *, method=None): page._crashed = False +def test_wait_for_single_propagates_owner_crash_when_probe_times_out_at_deadline(page, monkeypatch): + # Owner unavailability must win over a probe timeout even when the timeout coincides + # with the outer deadline. The top-of-loop owner check already covers the retry path, + # but a probe that both crashes the owner and outlasts the deadline would otherwise + # take the deadline-break path and raise a generic actionability timeout. The except + # block now surfaces the crash first, matching the fill/select-apply loops. + from rustwright.sync_api import Locator + + page.set_content("") + + def crashing_probe(self, timeout=None, **kwargs): + # Crash the owner and sleep past the tiny outer deadline before timing out, so the + # loop reaches the deadline break rather than re-polling on the next iteration. + self._page._crashed = True + time.sleep(0.05) + raise TimeoutError(f"timed out after {int(timeout)} ms") + + monkeypatch.setattr(Locator, "_target_state", crashing_probe) + + try: + with pytest.raises(Error, match="Page crashed"): + page.click("#go", timeout=10) + finally: + page._crashed = False + + def test_locator_fill_waits_for_delayed_editable(page): page.set_content( """ @@ -24097,6 +24123,62 @@ async def run() -> None: asyncio.run(run()) +def test_locator_eval_does_not_monopolize_gil_while_blocking(page): + # Invariant guard for the PR #95 GIL finding: while native ``locator_eval`` blocks + # on a slow in-page evaluate, it must not hold the Python GIL, so other Python + # threads keep running. This holds because ``locator_eval`` runs the CDP round-trip + # under ``py.detach`` (matching ``click`` / ``locator_eval_handle``); ``block_on`` + # also detaches internally, so the property predates and survives the binding-layer + # change -- this test locks it in against future refactors that could hold the GIL. + # + # The probe is a pure-Python busy-increment thread (no I/O -- ``time.sleep`` would + # release the GIL regardless). If ``locator_eval`` ever held the GIL for the whole + # blocking window, the busy thread would be starved to ~0 iterations, because native + # Rust code never reaches a bytecode boundary where CPython could rotate threads. + # Absolute iteration counts are very noisy (10-20x run-to-run), so the assertion + # only checks "not frozen" with a wide margin rather than a tight rate. + page.set_content("
hi
") + + stop = threading.Event() + progress = {"count": 0} + + def spin() -> None: + while not stop.is_set(): + progress["count"] += 1 + + worker = threading.Thread(target=spin, daemon=True) + worker.start() + try: + # Let the busy thread reach steady state before measuring. + time.sleep(0.05) + before = progress["count"] + started = time.monotonic() + # ``_eval`` routes straight through the native ``locator_eval`` binding. The JS + # body busy-waits ~750ms so the native call blocks for a measurable window. + page.locator("#target")._eval( + "const end = Date.now() + 750; while (Date.now() < end) {} return true;" + ) + elapsed = time.monotonic() - started + advanced = progress["count"] - before + finally: + stop.set() + worker.join(timeout=2) + + # Confirm the in-page busy loop actually blocked the native call (~0.75s), so the + # measurement window is real rather than a fast return. + assert elapsed >= 0.4, ( + f"native locator_eval returned too fast to prove blocking: {elapsed:.3f}s" + ) + # A frozen GIL would starve the busy thread to ~0 iterations. A released GIL lets it + # accumulate hundreds of thousands to millions; 50k is far above 0 yet far below the + # smallest observed released count, so it discriminates "frozen" from "released" + # without depending on the machine's exact throughput. + assert advanced > 50_000, ( + f"busy thread only advanced {advanced} iterations during a {elapsed:.3f}s " + "native locator_eval; the GIL appears to be held for the blocking call" + ) + + def test_connect_over_cdp_cancelled_creations_cleanup_after_last_browser_owner_drops(): async def run() -> None: from playwright.async_api import async_playwright