From 766f6c413fe15c9eec34ca0c5b8a70a43a906ac5 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:52:33 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'python/'?= =?UTF-8?q?=20with=20remote=20'python/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/241 --- python/rustwright/_async_generated.py | 2 +- python/rustwright/async_api.py | 388 ++++++++++++----- python/rustwright/sync_api.py | 605 +++++++++++++++++++++----- 3 files changed, 776 insertions(+), 219 deletions(-) diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index 35b3133..6e2670f 100644 --- a/python/rustwright/_async_generated.py +++ b/python/rustwright/_async_generated.py @@ -1,5 +1,5 @@ # This file is generated by tools/generate_async_api.py. Do not edit. -# sync_api.py sha256: 86dbd1ae4130f44b5d7fdcc0756a6b0c36f5aa57b75e7e4249f64142e29a49c3 +# sync_api.py sha256: 49b3f768b7d4abd70196e6caf57ef90a50b1d2ad57e0a826b0029a707e70dc90 from __future__ import annotations from pathlib import Path diff --git a/python/rustwright/async_api.py b/python/rustwright/async_api.py index b95c158..355b280 100644 --- a/python/rustwright/async_api.py +++ b/python/rustwright/async_api.py @@ -75,11 +75,16 @@ _default_timeout_for_method, _emit_event, _event_handler_positional_args, + _EVENT_DIALOG_CLAIM, + _SYNC_CLOSE_CLOSED, + _SYNC_CLOSE_CLOSING, + _raise_close_error, _EVENT_DISPATCH_OWNER, _EVENT_DISPATCH_REGISTRATION, _EVENT_DISPATCH_SEQUENCE, _json, _is_ignorable_close_error, + _validate_timeout_value, _sync_close_wait_deadline, _raise_close_wait_timeout, _update_sync_cleanup_complete, @@ -137,6 +142,9 @@ _DEFAULT_ASYNCIO_TO_THREAD = asyncio.to_thread +_ASYNC_CLOSE_OWNER: contextvars.ContextVar[Any] = contextvars.ContextVar( + "_rustwright_async_close_owner", default=None +) _ASYNC_EXECUTOR_LOCK = threading.Lock() _ASYNC_EXECUTOR: Optional[concurrent.futures.Executor] = None @@ -191,18 +199,36 @@ def async_executor_info() -> dict[str, Any]: "owned": owns_executor, "max_workers": getattr(executor, "_max_workers", None), } - - async def _run_sync_call(func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any: with _ASYNC_EXECUTOR_LOCK: executor = _ASYNC_EXECUTOR + owner = _ASYNC_CLOSE_OWNER.get() + + def call() -> Any: + if owner is None: + return func(*args, **kwargs) + condition = getattr(owner, "_rustwright_sync_close_condition", None) + if condition is None or not hasattr(condition, "__enter__"): + return func(*args, **kwargs) + current_thread = threading.get_ident() + with condition: + owner._rustwright_async_close_sync_owner = current_thread + condition.notify_all() + try: + return func(*args, **kwargs) + finally: + with condition: + if owner._rustwright_async_close_sync_owner == current_thread: + owner._rustwright_async_close_sync_owner = None + condition.notify_all() + if executor is None: - return await _DEFAULT_ASYNCIO_TO_THREAD(func, *args, **kwargs) + return await _DEFAULT_ASYNCIO_TO_THREAD(call) loop = asyncio.get_running_loop() ctx = contextvars.copy_context() - call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(executor, call) + wrapped_call = functools.partial(ctx.run, call) + return await loop.run_in_executor(executor, wrapped_call) async def _await_native(awaitable: Any) -> Any: @@ -246,7 +272,6 @@ async def _await_native_action(method: str, awaitable: Any) -> Any: error = error_type(f"{method}: {message}") raise _copy_wire_error_metadata(exc, error) from None - async def _await_cleanup_completion(awaitable: Any) -> Any: """Finish lifecycle cleanup before propagating cancellation to the caller.""" cleanup_task = asyncio.ensure_future(awaitable) @@ -266,6 +291,16 @@ async def _await_cleanup_completion(awaitable: Any) -> Any: _CLOSE_CLOSING = "closing" _CLOSE_CLOSED = "closed" +def _async_close_is_terminal(sync_obj: Any) -> bool: + condition = getattr(sync_obj, "_rustwright_sync_close_condition", None) + if condition is None or not hasattr(condition, "__enter__"): + return getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN) == _CLOSE_CLOSED + with condition: + return ( + getattr(sync_obj, "_rustwright_sync_close_state", _CLOSE_OPEN) == _SYNC_CLOSE_CLOSED + or getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN) == _CLOSE_CLOSED + ) + async def _single_flight_close(sync_obj: Any, cleanup: Callable[[], Any]) -> None: """Run one cancellation-safe close task and share its result with every caller.""" @@ -273,48 +308,115 @@ async def _single_flight_close(sync_obj: Any, cleanup: Callable[[], Any]) -> Non if condition is not None and not hasattr(condition, "__enter__"): condition = None - def read_state() -> tuple[str, Any]: - if condition is None: - return ( - getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN), - getattr(sync_obj, "_rustwright_async_close_task", None), - ) - with condition: - return ( - getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN), - getattr(sync_obj, "_rustwright_async_close_task", None), - ) - - def write_state(state: str, task: Any) -> None: - if condition is None: - sync_obj._rustwright_async_close_state = state - sync_obj._rustwright_async_close_task = task + async def owned_cleanup() -> Any: + token = _ASYNC_CLOSE_OWNER.set(sync_obj) + try: + return await cleanup() + finally: + _ASYNC_CLOSE_OWNER.reset(token) + + candidate = asyncio.create_task(owned_cleanup()) + current_thread = threading.get_ident() + if condition is None: + state = getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN) + task = getattr(sync_obj, "_rustwright_async_close_task", None) + if state == _CLOSE_CLOSED: + candidate.cancel() + return + if state == _CLOSE_CLOSING and task is not None: + candidate.cancel() + await _await_cleanup_completion(task) return + sync_obj._rustwright_async_close_state = _CLOSE_CLOSING + sync_obj._rustwright_async_close_sync_owner = current_thread + sync_obj._rustwright_async_close_task = candidate + owner = True + else: + role: str + task: Any = None + generation: Optional[int] = None + closed_error: Any = None with condition: - sync_obj._rustwright_async_close_state = state - sync_obj._rustwright_async_close_task = task - condition.notify_all() - - state, task = read_state() - if state == _CLOSE_CLOSED: - return - if state == _CLOSE_CLOSING and task is not None: - if task is asyncio.current_task(): + sync_state = getattr(sync_obj, "_rustwright_sync_close_state", _CLOSE_OPEN) + if sync_state == _SYNC_CLOSE_CLOSED: + role = "closed" + outcomes = getattr(sync_obj, "_rustwright_sync_close_outcomes", {}) + outcome = outcomes.get(getattr(sync_obj, "_rustwright_sync_close_generation", 0)) + closed_error = getattr(outcome, "error", None) + elif sync_state == _SYNC_CLOSE_CLOSING: + owner = getattr(sync_obj, "_rustwright_sync_close_owner", None) + if owner == current_thread: + role = "reentrant" + else: + role = "sync" + generation = getattr(sync_obj, "_rustwright_sync_close_generation", 0) + waiters = getattr(sync_obj, "_rustwright_sync_close_waiters", None) + if isinstance(waiters, dict): + waiters[generation] = waiters.get(generation, 0) + 1 + else: + async_state = getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN) + if async_state == _CLOSE_CLOSED: + role = "closed" + elif async_state == _CLOSE_CLOSING: + role = "async" + task = getattr(sync_obj, "_rustwright_async_close_task", None) + else: + sync_obj._rustwright_async_close_state = _CLOSE_CLOSING + sync_obj._rustwright_async_close_sync_owner = current_thread + sync_obj._rustwright_async_close_task = candidate + role = "owner" + owner = True + if role != "owner": + candidate.cancel() + if role == "closed" and closed_error is not None: + _raise_close_error(closed_error) + if role == "sync" and generation is not None: + wait_sync_generation = getattr(sync_obj, "_wait_for_sync_close_generation", None) + if callable(wait_sync_generation): + await _run_sync_call(wait_sync_generation, generation) + return + if role == "async" and task is not None: + if task is not asyncio.current_task(): + await _await_cleanup_completion(task) + return return - await _await_cleanup_completion(task) - return - task = asyncio.create_task(cleanup()) - write_state(_CLOSE_CLOSING, task) try: - await _await_cleanup_completion(task) + await _await_cleanup_completion(candidate) except BaseException: - write_state(_CLOSE_CLOSED if task.done() and not task.cancelled() and task.exception() is None else _CLOSE_OPEN, None) + native_disposed = getattr(sync_obj, "_rustwright_sync_close_native_disposed", None) + if native_disposed is None: + native_disposed = getattr(sync_obj, "_rustwright_async_close_native_disposed", None) + if native_disposed is None: + native_disposed = True + if condition is None: + complete = bool( + native_disposed + and getattr(sync_obj, "_rustwright_sync_close_cleanup_complete", False) + ) + sync_obj._rustwright_async_close_state = _CLOSE_CLOSED if complete else _CLOSE_OPEN + sync_obj._rustwright_async_close_task = None + else: + with condition: + complete = bool( + native_disposed + and getattr(sync_obj, "_rustwright_sync_close_cleanup_complete", False) + ) + sync_obj._rustwright_async_close_state = _CLOSE_CLOSED if complete else _CLOSE_OPEN + sync_obj._rustwright_async_close_task = None + sync_obj._rustwright_async_close_sync_owner = None + condition.notify_all() raise else: - write_state(_CLOSE_CLOSED, None) - - + if condition is None: + sync_obj._rustwright_async_close_state = _CLOSE_CLOSED + sync_obj._rustwright_async_close_task = None + else: + with condition: + sync_obj._rustwright_async_close_state = _CLOSE_CLOSED + sync_obj._rustwright_async_close_task = None + sync_obj._rustwright_async_close_sync_owner = None + condition.notify_all() def _native_normalize_selector(selector: str, *, method: str) -> str: missing_method = "_click" if method == "Page.click" else method.rsplit(".", 1)[-1] return _normalize_selector_option( @@ -1320,6 +1422,17 @@ def _wrap_async_event_handler(owner: Any, event: str, handler: Any) -> Any: def wrapper(*args: Any) -> None: sync_owner = getattr(owner, "_sync", None) + dialog_claim = _EVENT_DIALOG_CLAIM.get() if event == "dialog" else None + claim_released = False + + def release_dialog_claim() -> None: + nonlocal claim_released + if claim_released or dialog_claim is None or not args: + return + claim_released = True + release = getattr(args[0], "_release_handler_claim", None) + if callable(release): + release(dialog_claim) def call_handler() -> Any: mapped_args = tuple(_wrap_async_event_value(event, arg) for arg in args) @@ -1333,10 +1446,15 @@ async def await_result() -> Any: if not callable(activate) or not activate(state, task): if inspect.iscoroutine(result): result.close() + release_dialog_claim() return None owner_token = _EVENT_DISPATCH_OWNER.set(task) try: - return await result + try: + return await result + except BaseException: + release_dialog_claim() + return None finally: _EVENT_DISPATCH_OWNER.reset(owner_token) if state is not None: @@ -1350,19 +1468,26 @@ def invoke_on_loop(callback_owner: Any, state: Any) -> None: owner_token = _EVENT_DISPATCH_OWNER.set(callback_owner) active = False deferred = False + handler_raised = False try: if state is not None: activate = getattr(sync_owner, "_activate_deferred_event_handler", None) if not callable(activate) or not activate(state, callback_owner): + release_dialog_claim() return active = True - result = call_handler() + try: + result = call_handler() + except BaseException: + handler_raised = True + raise if _should_await_callback_result(result): if state is not None: deferred_state = getattr(sync_owner, "_defer_current_event_handler", lambda: None)() if deferred_state is None: if inspect.iscoroutine(result): result.close() + release_dialog_claim() return deferred = True try: @@ -1371,37 +1496,54 @@ def invoke_on_loop(callback_owner: Any, state: Any) -> None: release = getattr(sync_owner, "_release_deferred_event_handler", None) if callable(release): release(deferred_state) + release_dialog_claim() raise else: _run_awaitable_on_loop(loop, result) finally: - if active and not deferred: - finish = getattr(sync_owner, "_finish_event_handler", None) - if callable(finish): - finish(state, callback_owner) + if not deferred: + if handler_raised: + release_dialog_claim() + if active: + finish = getattr(sync_owner, "_finish_event_handler", None) + if callable(finish): + finish(state, callback_owner) _EVENT_DISPATCH_OWNER.reset(owner_token) def invoke_in_current_loop() -> None: - result = call_handler() - if not _should_await_callback_result(result): - return - state = _EVENT_DISPATCH_REGISTRATION.get() - defer = getattr(sync_owner, "_defer_current_event_handler", None) - deferred_state = defer() if callable(defer) else None - if state is not None and deferred_state is None: - if inspect.iscoroutine(result): - result.close() - return - if deferred_state is not None: + deferred = False + handler_raised = False + try: try: - run_awaitable_with_state(result, deferred_state, loop) + result = call_handler() except BaseException: - release = getattr(sync_owner, "_release_deferred_event_handler", None) - if callable(release): - release(deferred_state) + handler_raised = True raise - else: - _run_awaitable_on_loop(loop, result) + if not _should_await_callback_result(result): + return + state = _EVENT_DISPATCH_REGISTRATION.get() + defer = getattr(sync_owner, "_defer_current_event_handler", None) + deferred_state = defer() if callable(defer) else None + if state is not None and deferred_state is None: + if inspect.iscoroutine(result): + result.close() + release_dialog_claim() + return + if deferred_state is not None: + deferred = True + try: + run_awaitable_with_state(result, deferred_state, loop) + except BaseException: + release = getattr(sync_owner, "_release_deferred_event_handler", None) + if callable(release): + release(deferred_state) + release_dialog_claim() + raise + else: + _run_awaitable_on_loop(loop, result) + finally: + if not deferred and handler_raised: + release_dialog_claim() loop = getattr(owner, "_loop", None) if loop is not None and loop.is_running(): @@ -1427,11 +1569,16 @@ def run_on_loop() -> None: release = getattr(sync_owner, "_release_deferred_event_handler", None) if callable(release): release(state) + release_dialog_claim() raise return - result = call_handler() - _run_awaitable(result) + try: + result = call_handler() + _run_awaitable(result) + except BaseException: + release_dialog_claim() + raise wrappers.append((event, handler, wrapper)) return wrapper @@ -2215,6 +2362,7 @@ async def new_page( else: context._finish_page_creation() page._owns_context = True + page._rustwright_sync_close_default_context_cleaned = False return _wrap_async_page(page) return _wrap_async_page(await _run_sync_call(self._sync.new_page, **options)) @@ -2287,6 +2435,8 @@ async def new_context( return _wrap_async_browser_context(await _run_sync_call(self._sync.new_context, **options)) async def close(self, *, reason: Optional[str] = None) -> None: + if _async_close_is_terminal(self._sync): + return if not isinstance(self._sync, SyncBrowser): await _single_flight_close( self._sync, @@ -2382,6 +2532,8 @@ async def new_page(self) -> "AsyncPage": return _wrap_async_page(await _run_sync_call(self._sync.new_page)) async def close(self, *, reason: Optional[str] = None) -> None: + if _async_close_is_terminal(self._sync): + return browser = getattr(self._sync, "_browser", None) if getattr(self._sync, "_owns_browser", False) and browser is not None: normalized_reason = None @@ -2582,7 +2734,7 @@ async def _event_pump(self) -> None: await _await_native(self._sync._event_stream.wait_batch_async(500.0, 64)) ) except asyncio.CancelledError: - self._sync._event_stream.rollback_batch() + await _await_cleanup_completion(self._sync._event_stream.cancel_wait_async()) return except RuntimeError as exc: if "page event stream is already waiting" not in str(exc): @@ -2597,7 +2749,9 @@ async def _event_pump(self) -> None: try: stream_closed = await _await_cleanup_completion(self._consume_event_batch(batch)) except asyncio.CancelledError: + await _await_cleanup_completion(self._sync._event_stream.cancel_wait_async()) return + self._sync._note_event_batch_consumed(stream_closed) if stream_closed: self._sync._stop_event_pump() return @@ -2833,6 +2987,11 @@ async def goto( ) ): raise Error("Page.goto: Download is starting") from None + if self._sync._crashed or ( + target.lower().startswith("chrome://crash") + and message.startswith("Page.goto: net::ERR_ABORTED") + ): + raise Error("Page crashed") from None raise response = ( None @@ -3332,6 +3491,8 @@ async def remove_locator_handler(self, locator: Any) -> None: await _run_sync_call(self._sync.remove_locator_handler, sync_locator) async def close(self, *, run_before_unload: Optional[bool] = None, reason: Optional[str] = None) -> None: + if _async_close_is_terminal(self._sync): + return if not isinstance(self._sync, SyncPage): await _single_flight_close( self._sync, @@ -3342,10 +3503,6 @@ async def close(self, *, run_before_unload: Optional[bool] = None, reason: Optio ), ) return - if self._sync._closed or getattr( - self._sync, "_rustwright_async_close_state", _CLOSE_OPEN - ) == _CLOSE_CLOSED: - return if ( self._sync._video is not None or self._sync._har_recordings @@ -3373,56 +3530,53 @@ async def close(self, *, run_before_unload: Optional[bool] = None, reason: Optio async def _close_native(self, normalized_reason: Optional[str], unload: bool) -> None: self._sync._closed_reason = normalized_reason - if self._sync._owns_context and self._sync._context is not None: - await _run_sync_call(self._sync._context._cleanup_default_context_state) - dialog_dispatch_count = self._sync._dialog_dispatch_count + first_error: Optional[BaseException] = None + native_attempted = self._sync._rustwright_sync_close_native_disposed try: - try: - await _await_native_method( - "Page.close", - self._sync._core.close_async(self._sync._default_timeout, unload) - ) - except Error as exc: - if not _is_ignorable_close_error(exc): - raise - if unload and self._sync._event_handlers.get("dialog"): - deadline = time.monotonic() + min(self._sync._default_timeout / 1000, 0.5) - while ( - self._sync._dialog_dispatch_count == dialog_dispatch_count - and time.monotonic() < deadline + await _run_sync_call(self._sync._purge_owned_default_context) + if not self._sync._rustwright_sync_close_native_disposed: + with self._sync._event_dispatch_condition: + beforeunload_dispatch_count = self._sync._beforeunload_dispatch_count + if not unload: + self._sync._closing = True + native_attempted = True + try: + await _await_native_method( + "Page.close", + self._sync._core.close_async(self._sync._default_timeout, unload), + ) + except Error as exc: + if not _is_ignorable_close_error(exc): + raise + self._sync._rustwright_sync_close_native_disposed = True + if unload: + self._sync._rustwright_sync_close_beforeunload_count = beforeunload_dispatch_count + if self._sync._rustwright_sync_close_beforeunload_count is not None: + if not await _run_sync_call( + self._sync._wait_for_close_event_dispatch_or_fallback, + self._sync._rustwright_sync_close_beforeunload_count, ): - await asyncio.sleep(0.01) + raise TimeoutError("Page.close: timed out waiting for beforeunload event dispatch; retry close") + self._sync._rustwright_sync_close_beforeunload_count = None + except BaseException as exc: + first_error = exc finally: - self._sync._stop_event_pump() - pump_task = self._event_pump_task - if pump_task is not None and pump_task is not asyncio.current_task(): - await asyncio.gather(pump_task, return_exceptions=True) - self._sync._closed = True - self._sync._closing = True - self._sync._mark_owned_cdp_sessions_closed() - if self._sync._context is not None and self._sync in self._sync._context._pages: - self._sync._context._pages.remove(self._sync) - _emit_event(self._sync._event_handlers, "close", self._sync) - self._sync._release_memory_buffers() - if ( - self._sync._owns_context - and self._sync._context is not None - and getattr( - self._sync._context, - "_rustwright_sync_close_state", - _CLOSE_OPEN, - ) - == _CLOSE_OPEN - and getattr( - self._sync._context, - "_rustwright_async_close_state", - _CLOSE_OPEN, - ) - != _CLOSE_CLOSING - ): - await _wrap_async_browser_context(self._sync._context).close() - - + if native_attempted and self._sync._rustwright_sync_close_beforeunload_count is None: + try: + self._sync._stop_event_pump() + pump_task = self._event_pump_task + if pump_task is not None and pump_task is not asyncio.current_task(): + await asyncio.gather(pump_task, return_exceptions=True) + except BaseException as exc: + if first_error is None: + first_error = exc + try: + await _run_sync_call(self._sync._run_close_cleanup) + except BaseException as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error class AsyncJSHandle(_AsyncJSHandleGeneratedMixin, _AsyncWrapper): def __str__(self) -> str: return str(self._sync) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 79a949e..af4df7a 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -1933,6 +1933,22 @@ def _update_sync_cleanup_complete(context: Any) -> None: context._rustwright_sync_close_cleanup_complete = _is_context_cleanup_complete(context) +def _is_page_cleanup_complete(page: Any) -> bool: + return all( + bool(getattr(page, name, False)) + for name in ( + "_rustwright_sync_close_sessions_closed", + "_rustwright_sync_close_default_context_cleaned", + "_rustwright_sync_close_context_removed", + "_rustwright_sync_close_close_event_emitted", + "_rustwright_sync_close_memory_released", + "_rustwright_sync_close_owned_context_closed", + ) + ) + +def _update_page_cleanup_complete(page: Any) -> None: + page._rustwright_sync_close_cleanup_complete = _is_page_cleanup_complete(page) + _CLOSE_WAIT_FALLBACK_MS = 30_000.0 @@ -3945,6 +3961,10 @@ def _emit_local_event(self, event: str, *args: Any) -> None: "rustwright_event_dialog_dispatch", default=None, ) +_EVENT_DIALOG_CLAIM: contextvars.ContextVar[Any] = contextvars.ContextVar( + "rustwright_event_dialog_claim", + default=None, +) class _DialogDispatch: @@ -3954,6 +3974,8 @@ class _DialogDispatch: "remaining", "finished", "captured", + "handler_claims", + "next_claim", "handled", "fallback_started", "lock", @@ -3965,6 +3987,8 @@ def __init__(self, page: Any, dialog: Any, remaining: int) -> None: self.remaining = remaining self.finished = False self.captured = False + self.handler_claims: set[int] = set() + self.next_claim = 0 self.handled = False self.fallback_started = False self.lock = threading.Lock() @@ -3973,6 +3997,26 @@ def capture(self) -> None: with self.lock: self.captured = True + def release_capture(self) -> None: + with self.lock: + self.captured = False + + def claim_handler(self) -> int: + with self.lock: + self.next_claim += 1 + claim = self.next_claim + self.handler_claims.add(claim) + return claim + + def release_handler_claim(self, claim: Optional[int]) -> None: + if claim is None: + return + with self.lock: + self.handler_claims.discard(claim) + finished = self.finished + if finished: + self.maybe_fallback() + def mark_handled(self) -> None: with self.lock: self.handled = True @@ -4004,6 +4048,7 @@ def maybe_fallback(self) -> None: if ( not self.finished or self.captured + or self.handler_claims or self.handled or self.fallback_started ): @@ -8988,6 +9033,7 @@ def _wait_for_descriptor_event_impl( timeout, method=method or descriptor.timeout_method, ) + event_state = state if state is not None else {} if waiter is None: waiter = _create_descriptor_waiter(target, descriptor, deadline, timeout_display) if descriptor.competing_waiter_factory is not None: @@ -8999,7 +9045,6 @@ def _wait_for_descriptor_event_impl( and descriptor.competing_waiter_factory is None else None ) - event_state = state or {} native_wait_matching = getattr(waiter, "wait_matching", None) native_wait = kind in {"console", "pageerror"} and callable(native_wait_matching) native_matcher = _native_event_matcher(kind, matcher) if native_wait else None @@ -9309,6 +9354,9 @@ def __init__( self._deadline: Optional[float] = None self._timeout_display = "" self._dialog_owner_release: Optional[Callable[[], None]] = None + self._state_lock = threading.Lock() + self._error: Optional[BaseException] = None + self._deadline_expired = False def __enter__(self) -> "_ListenerEventContextManager": descriptor = _event_waiter_descriptor(self._kind) @@ -9322,16 +9370,43 @@ def __enter__(self) -> "_ListenerEventContextManager": self._dialog_owner_release = self._page._acquire_dialog_waiter() def handler(value: Any) -> None: - if self._value is None and descriptor.matches(self._page, self._matcher, value): - owner_release = self._dialog_owner_release - self._dialog_owner_release = None - if self._kind == "dialog" and isinstance(value, Dialog): - self._page._capture_dialog(value, owner_release) - elif owner_release is not None: - owner_release() - self._value = value - self._ready.set() - + try: + matched = descriptor.matches(self._page, self._matcher, value) + except BaseException as exc: + with self._state_lock: + if self._error is None and self._value is None and not self._deadline_expired: + self._error = exc + self._ready.set() + release = self._dialog_owner_release + self._dialog_owner_release = None + else: + release = self._dialog_owner_release + self._dialog_owner_release = None + if release is not None: + release() + return + if not matched: + return + with self._state_lock: + if self._value is not None or self._error is not None or self._deadline_expired: + release = self._dialog_owner_release + self._dialog_owner_release = None + capture = False + else: + self._value = value + release = self._dialog_owner_release + self._dialog_owner_release = None + capture = True + if self._kind == "dialog" and isinstance(value, Dialog): + self._page._capture_dialog(value, release) + release = None + self._ready.set() + if not capture and release is not None: + release() + elif capture and release is not None: + release() + if self._kind == "dialog": + setattr(handler, "_rustwright_dialog_predicate_waiter", True) self._handler = handler try: self._page.on(descriptor.event, handler) @@ -9356,21 +9431,35 @@ def __exit__(self, exc_type, exc, tb) -> None: if self._deadline is None else max((self._deadline - time.monotonic()) * 1000, 0) ) - _wait_for_ready_event_or_owner_close( - self._ready, - self._page, - descriptor.event, - timeout_ms, - self._timeout_display, - deadline=self._deadline, - ) + try: + _wait_for_ready_event_or_owner_close( + self._ready, + self._page, + descriptor.event, + timeout_ms, + self._timeout_display, + deadline=self._deadline, + ) + except BaseException: + with self._state_lock: + won = self._value is not None or self._error is not None + if not won: + self._deadline_expired = True + if not won: + raise + with self._state_lock: + error = self._error + if error is not None: + raise error finally: if self._handler is not None: self._page.remove_listener(descriptor.event, self._handler) self._handler = None - if self._dialog_owner_release is not None: - self._dialog_owner_release() + with self._state_lock: + release = self._dialog_owner_release self._dialog_owner_release = None + if release is not None: + release() @property def value(self) -> Any: @@ -10274,6 +10363,30 @@ def _capture(self, owner_release: Optional[Callable[[], None]] = None) -> None: dispatch.capture() if release_now: owner_release() + def _claim_handler(self) -> Optional[int]: + dispatch = self._dispatch + return dispatch.claim_handler() if dispatch is not None else None + + def _release_handler_claim(self, claim: Optional[int]) -> None: + dispatch = self._dispatch + if dispatch is not None: + dispatch.release_handler_claim(claim) + + def _release_capture(self) -> None: + with self._fallback_lock: + self._captured = False + dispatch = self._dispatch + if dispatch is not None: + dispatch.release_capture() + + def _release_capture_if_unowned(self) -> None: + with self._fallback_lock: + if self._handled or self._owner_releases or not self._captured: + return + self._captured = False + dispatch = self._dispatch + if dispatch is not None: + dispatch.release_capture() def _mark_handled(self) -> None: with self._fallback_lock: @@ -11894,6 +12007,7 @@ def _new_page_from_options(self, options: Dict[str, Any]) -> "Page": context.close() raise page._owns_context = True + page._rustwright_sync_close_default_context_cleaned = False return page def new_context( @@ -16307,7 +16421,7 @@ def __init__( self._auth_waiter: Any = None self._proxy_auth_credentials: Optional[tuple[str, str]] = None self._http_auth_credentials: Optional[tuple[str, str, Optional[str]]] = None - self._dialog_dispatch_count = 0 + self._beforeunload_dispatch_count = 0 self._dialog_state_lock = threading.Lock() self._dialog_waiter_count = 0 self._dialog_operation_count = 0 @@ -16379,7 +16493,22 @@ def __init__( self._clock_initialized: set[str] = set() self._closing = False self._closed = False + self._rustwright_sync_close_state = _SYNC_CLOSE_OPEN + self._rustwright_sync_close_condition = threading.Condition() + self._rustwright_sync_close_owner: Optional[int] = None + self._rustwright_sync_close_generation = 0 + self._rustwright_sync_close_outcomes: dict[int, _CloseAttemptOutcome] = {} + self._rustwright_sync_close_waiters: dict[int, int] = {} + self._rustwright_sync_close_native_disposed = False + self._rustwright_sync_close_beforeunload_count: Optional[int] = None + self._rustwright_sync_close_close_event_emitted = False + self._rustwright_sync_close_default_context_cleaned = True + self._rustwright_sync_close_owned_context_closed = False + self._rustwright_sync_close_cleanup_complete = False + self._rustwright_sync_close_sessions_closed = False + self._rustwright_sync_close_context_removed = False self._rustwright_async_close_state = "open" + self._rustwright_async_close_sync_owner: Optional[int] = None self._rustwright_async_close_task: Any = None self._closed_reason: Optional[str] = None self._event_pump_stop_lock = threading.Lock() @@ -16388,6 +16517,7 @@ def __init__( self._event_dispatch_condition = threading.Condition(self._event_dispatch_lock) self._event_handler_registrations: dict[tuple[str, int], _EventHandlerRegistration] = {} self._event_stream = self._core.combined_event_stream() + self._event_pump_terminal_consumed = False self._event_pump_thread: Optional[threading.Thread] = None if _start_event_pump: self._event_pump_thread = threading.Thread( @@ -17313,9 +17443,6 @@ def goto( "referer": normalized_referer, }, ) - single_process_crash_navigation = ( - self._uses_single_process_fallback() and target_url.lower().startswith("chrome://crash") - ) try: try: target_scheme = url_parse.urlparse(target_url).scheme.lower() @@ -17357,9 +17484,11 @@ def goto( and self._download_started_for_url(download_waiter, target_url, timeout=1_000.0) ): raise Error("Page.goto: Download is starting") from None - if target_url.lower().startswith("chrome://crash") and message.startswith("Page.goto: net::ERR_ABORTED"): + if self._crashed or ( + target_url.lower().startswith("chrome://crash") + and message.startswith("Page.goto: net::ERR_ABORTED") + ): crash_error = Error("Page crashed") - self._mark_crashed() raise crash_error from None if message.startswith("Protocol error (Page.navigate):"): raise Error(f"Page.goto: {message}") from None @@ -17407,9 +17536,8 @@ def goto( self._slow_mo() except Exception as exc: self._clear_context_pageload_pending() - if single_process_crash_navigation: + if self._crashed: crash_error = Error("Page crashed") - self._mark_crashed() self._trace_end_action(call_id, error=crash_error) raise crash_error from exc self._trace_end_action(call_id, error=exc) @@ -20847,6 +20975,10 @@ def _mark_owned_cdp_sessions_closed(self) -> None: for session in sessions: session._mark_owner_closed() + def _remove_page_from_context(self) -> None: + if self._context is not None and self in self._context._pages: + self._context._pages.remove(self) + def bring_to_front(self) -> None: self.evaluate("() => window.focus()") @@ -21226,69 +21358,307 @@ def aria_snapshot( depth=_normalize_aria_snapshot_depth(depth, method="Page.aria_snapshot"), mode=_normalize_aria_snapshot_mode(mode, method="Page.aria_snapshot"), ) + def _acquire_sync_close_follower(self) -> Optional[int]: + current_thread = threading.get_ident() + with self._rustwright_sync_close_condition: + if self._rustwright_sync_close_state != _SYNC_CLOSE_CLOSING: + return None + if self._rustwright_sync_close_owner == current_thread: + return None + generation = self._rustwright_sync_close_generation + self._rustwright_sync_close_waiters[generation] = ( + self._rustwright_sync_close_waiters.get(generation, 0) + 1 + ) + return generation + + def _wait_for_sync_close_generation(self, generation: int) -> None: + deadline = _sync_close_wait_deadline(self) + with self._rustwright_sync_close_condition: + try: + while generation not in self._rustwright_sync_close_outcomes: + remaining = deadline - time.monotonic() + if remaining <= 0: + _raise_close_wait_timeout("Page.close") + self._rustwright_sync_close_condition.wait(timeout=remaining) + outcome = self._rustwright_sync_close_outcomes[generation] + finally: + waiter_count = self._rustwright_sync_close_waiters.get(generation, 0) + if waiter_count <= 1: + self._rustwright_sync_close_waiters.pop(generation, None) + else: + self._rustwright_sync_close_waiters[generation] = waiter_count - 1 + if outcome.error is not None: + _raise_close_error(outcome.error) + + def _fallback_close_dialog(self) -> None: + self._maybe_fallback_pending_dialogs() + + + def _wait_for_sync_close_completion(self) -> None: + generation = self._acquire_sync_close_follower() + if generation is not None: + self._wait_for_sync_close_generation(generation) + + def _begin_sync_close(self) -> bool: + current_thread = threading.get_ident() + deadline = _sync_close_wait_deadline(self) + with self._rustwright_sync_close_condition: + while ( + self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSING + or ( + self._rustwright_async_close_state == "closing" + and self._rustwright_async_close_sync_owner != current_thread + ) + ): + if ( + self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSING + and self._rustwright_sync_close_owner == current_thread + ): + return False + if self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSING: + waited_generation = self._rustwright_sync_close_generation + self._rustwright_sync_close_waiters[waited_generation] = ( + self._rustwright_sync_close_waiters.get(waited_generation, 0) + 1 + ) + try: + while waited_generation not in self._rustwright_sync_close_outcomes: + remaining = deadline - time.monotonic() + if remaining <= 0: + _raise_close_wait_timeout("Page.close") + self._rustwright_sync_close_condition.wait(timeout=remaining) + outcome = self._rustwright_sync_close_outcomes[waited_generation] + finally: + waiter_count = self._rustwright_sync_close_waiters.get(waited_generation, 0) + if waiter_count <= 1: + self._rustwright_sync_close_waiters.pop(waited_generation, None) + else: + self._rustwright_sync_close_waiters[waited_generation] = waiter_count - 1 + if outcome.error is not None: + _raise_close_error(outcome.error) + return False + remaining = deadline - time.monotonic() + if remaining <= 0: + _raise_close_wait_timeout("Page.close") + self._rustwright_sync_close_condition.wait(timeout=remaining) + if self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + outcome = self._rustwright_sync_close_outcomes.get(self._rustwright_sync_close_generation) + if outcome is not None and outcome.error is not None: + _raise_close_error(outcome.error) + return False + for generation in tuple(self._rustwright_sync_close_outcomes): + if not self._rustwright_sync_close_waiters.get(generation, 0): + self._rustwright_sync_close_outcomes.pop(generation, None) + self._rustwright_sync_close_state = _SYNC_CLOSE_CLOSING + self._rustwright_sync_close_owner = current_thread + self._rustwright_sync_close_generation += 1 + return True + def _finish_sync_close_success(self) -> None: + with self._rustwright_sync_close_condition: + generation = self._rustwright_sync_close_generation + self._rustwright_sync_close_outcomes[generation] = _CloseAttemptOutcome(generation) + self._rustwright_sync_close_state = _SYNC_CLOSE_CLOSED + self._rustwright_sync_close_owner = None + self._rustwright_sync_close_condition.notify_all() + + def _finish_sync_close_failure(self, error: BaseException) -> None: + with self._rustwright_sync_close_condition: + generation = self._rustwright_sync_close_generation + snapshot = _CloseErrorSnapshot.from_exception(error) + self._rustwright_sync_close_outcomes[generation] = _CloseAttemptOutcome( + generation, snapshot + ) + terminal = ( + getattr(self, "_rustwright_sync_close_native_disposed", False) + and getattr(self, "_rustwright_sync_close_cleanup_complete", False) + ) + self._closed = terminal + self._closing = terminal + self._rustwright_sync_close_state = _SYNC_CLOSE_CLOSED if terminal else _SYNC_CLOSE_OPEN + self._rustwright_sync_close_owner = None + self._rustwright_sync_close_condition.notify_all() + def _note_event_batch_consumed(self, terminal: bool) -> None: + with self._event_dispatch_condition: + # Native cursors track reads; several drained batches can share one. + # Only consumption of the terminal envelope completes this barrier. + self._event_pump_terminal_consumed |= terminal + self._event_dispatch_condition.notify_all() + + def _wait_for_close_event_dispatch_or_fallback( + self, + initial_beforeunload_count: Optional[int] = None, + ) -> bool: + deadline = _sync_close_wait_deadline(self) + with self._event_dispatch_condition: + while True: + if ( + initial_beforeunload_count is not None + and self._beforeunload_dispatch_count != initial_beforeunload_count + ): + return True + if self._event_pump_terminal_consumed: + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + self._fallback_close_dialog() + return False + self._event_dispatch_condition.wait(timeout=remaining) + + def close(self, *, run_before_unload: Optional[bool] = None, reason: Optional[str] = None) -> None: - if self._closed: - return + with self._rustwright_sync_close_condition: + if self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + return normalized_reason = None if reason is not None: normalized_reason = _normalize_string_option(reason, method="Page.close", name="reason") - self._closed_reason = normalized_reason - run_before_unload = bool(run_before_unload or False) - if not run_before_unload: - self._closing = True - if self._video is not None: - self._video._finalize() - self._screencast.stop() - for har_path, url_filter, content_mode, har_mode in self._har_recordings: - _write_har(har_path, [self], url_filter, content_mode=content_mode, har_mode=har_mode) - if self._fetch_enabled: + if not self._begin_sync_close(): + return + try: + self._close_impl( + normalized_reason=normalized_reason, + run_before_unload=bool(run_before_unload or False), + ) + except BaseException as exc: + self._finish_sync_close_failure(exc) + raise + else: + self._finish_sync_close_success() + + def _purge_owned_default_context(self) -> None: + if self._rustwright_sync_close_default_context_cleaned: + return + if self._owns_context and self._context is not None: + self._context._cleanup_default_context_state() + self._rustwright_sync_close_default_context_cleaned = True + + def _run_close_cleanup(self) -> None: + self._closed = False + self._closing = True + first_error: Optional[BaseException] = None + + def run_cleanup(stage: Callable[[], None], marker: str) -> None: + nonlocal first_error + if getattr(self, marker, False): + return try: - _call(self._core.disable_fetch, self._default_timeout) - except Error: - pass - self._fetch_enabled = False - if self._binding_server is not None: - self._binding_server.shutdown() - self._binding_server.server_close() - self._binding_server = None - if self._crash_session is not None: + stage() + except BaseException as exc: + if first_error is None: + first_error = exc + return + setattr(self, marker, True) + _update_page_cleanup_complete(self) + + run_cleanup( + self._mark_owned_cdp_sessions_closed, + "_rustwright_sync_close_sessions_closed", + ) + run_cleanup( + self._remove_page_from_context, + "_rustwright_sync_close_context_removed", + ) + if not self._rustwright_sync_close_close_event_emitted: + self._closed = True try: - self._crash_session.detach() - except Error: - pass - self._crash_session = None - self._crash_waiter = None - dialog_dispatch_count = self._dialog_dispatch_count + _emit_event(self._event_handlers, "close", self) + except BaseException as exc: + first_error = first_error or exc + else: + self._rustwright_sync_close_close_event_emitted = True + _update_page_cleanup_complete(self) + run_cleanup(self._release_memory_buffers, "_rustwright_sync_close_memory_released") + if not self._rustwright_sync_close_owned_context_closed: + def close_owned_context() -> None: + if not self._owns_context: + return + if ( + self._context is not None + and getattr(self._context, "_rustwright_sync_close_state", _SYNC_CLOSE_OPEN) + == _SYNC_CLOSE_OPEN + and getattr(self._context, "_rustwright_async_close_state", "open") != "closing" + ): + self._context.close() + + run_cleanup(close_owned_context, "_rustwright_sync_close_owned_context_closed") + _update_page_cleanup_complete(self) + native_disposed = getattr(self, "_rustwright_sync_close_native_disposed", True) + if self._rustwright_sync_close_cleanup_complete and native_disposed: + self._closed = True + self._closing = True + else: + self._closed = False + self._closing = False + if first_error is not None: + raise first_error + + def _close_impl(self, *, normalized_reason: Optional[str], run_before_unload: bool) -> None: + self._closed_reason = normalized_reason + first_error: Optional[BaseException] = None + native_attempted = self._rustwright_sync_close_native_disposed try: - try: - _call(self._core.close, self._default_timeout, run_before_unload) - except Error as exc: - if not _is_ignorable_close_error(exc): - raise - if run_before_unload and self._event_handlers.get("dialog"): - deadline = time.monotonic() + min(self._default_timeout / 1000, 0.5) - while self._dialog_dispatch_count == dialog_dispatch_count and time.monotonic() < deadline: - time.sleep(0.01) + if self._video is not None: + self._video._finalize() + self._screencast.stop() + for har_path, url_filter, content_mode, har_mode in self._har_recordings: + _write_har(har_path, [self], url_filter, content_mode=content_mode, har_mode=har_mode) + if self._fetch_enabled: + try: + _call(self._core.disable_fetch, self._default_timeout) + except Error: + pass + self._fetch_enabled = False + if self._binding_server is not None: + self._binding_server.shutdown() + self._binding_server.server_close() + self._binding_server = None + if self._crash_session is not None: + try: + self._crash_session.detach() + except Error: + pass + self._crash_session = None + self._crash_waiter = None + if not self._rustwright_sync_close_native_disposed: + self._purge_owned_default_context() + with self._event_dispatch_condition: + beforeunload_dispatch_count = self._beforeunload_dispatch_count + if not run_before_unload: + self._closing = True + native_attempted = True + try: + _call(self._core.close, self._default_timeout, run_before_unload) + except Error as exc: + if not _is_ignorable_close_error(exc): + raise + self._rustwright_sync_close_native_disposed = True + if run_before_unload: + self._rustwright_sync_close_beforeunload_count = beforeunload_dispatch_count + if self._rustwright_sync_close_beforeunload_count is not None: + if not self._wait_for_close_event_dispatch_or_fallback( + self._rustwright_sync_close_beforeunload_count + ): + raise TimeoutError("Page.close: timed out waiting for beforeunload event dispatch; retry close") + self._rustwright_sync_close_beforeunload_count = None + except BaseException as exc: + first_error = exc finally: - self._stop_event_pump() - self._detach_worker_console_propagation(force=True) - self._stop_worker_thread() - self._reap_worker_event_threads() - self._closed = True - self._closing = True - self._mark_owned_cdp_sessions_closed() - if self._context is not None and self in self._context._pages: - self._context._pages.remove(self) - _emit_event(self._event_handlers, "close", self) - self._release_memory_buffers() - if ( - self._owns_context - and self._context is not None - and getattr(self._context, "_rustwright_sync_close_state", _SYNC_CLOSE_OPEN) == _SYNC_CLOSE_OPEN - and getattr(self._context, "_rustwright_async_close_state", "open") != "closing" - ): - self._context.close() + if native_attempted and self._rustwright_sync_close_beforeunload_count is None: + try: + self._stop_event_pump() + self._detach_worker_console_propagation(force=True) + self._stop_worker_thread() + self._reap_worker_event_threads() + except BaseException as exc: + if first_error is None: + first_error = exc + try: + self._run_close_cleanup() + except BaseException as exc: + if first_error is None: + first_error = exc + if first_error is not None: + raise first_error def get_by_text(self, text: str, *, exact: bool = False) -> "Locator": return Locator(self, {"kind": "text", "text": _text_matcher(text), "exact": exact}) @@ -21435,7 +21805,6 @@ def _finish_event_handler( dispatch = _EVENT_DIALOG_DISPATCH.get() if isinstance(dispatch, _DialogDispatch): dispatch.settle() - def _wait_for_event_handler_removal( self, event: str, @@ -21497,10 +21866,7 @@ def _listener_registration_cursor(self, event: str) -> Optional[int]: return None def _dialog_fallback_suppressed(self, dialog: Dialog) -> bool: - if getattr(dialog, "_captured", False): - return True - with self._dialog_state_lock: - return self._dialog_waiter_count > 0 or self._dialog_operation_count > 0 + return bool(getattr(dialog, "_captured", False)) def _dialog_dispatch_finished(self, dispatch: _DialogDispatch) -> None: if getattr(dispatch.dialog, "_handled", False): @@ -21595,20 +21961,31 @@ def _dispatch_event_handlers( dialog_dispatch.settle() continue activated = True - if event == "dialog" and args and self._dialog_operation_active(): - capture = getattr(args[0], "_capture", None) - if callable(capture): - capture() sequence_token = _EVENT_DISPATCH_SEQUENCE.set((self, event_sequence)) owner_token = _EVENT_DISPATCH_OWNER.set(owner) registration_token = _EVENT_DISPATCH_REGISTRATION.set(state) deferred_token = _EVENT_DISPATCH_DEFERRED.set(False) dialog_token = _EVENT_DIALOG_DISPATCH.set(dialog_dispatch) + claim: Optional[int] = None + if ( + event == "dialog" + and args + and not getattr(state.handler, "_rustwright_dialog_predicate_waiter", False) + ): + claim_handler = getattr(args[0], "_claim_handler", None) + if callable(claim_handler): + claim = claim_handler() + claim_token = _EVENT_DIALOG_CLAIM.set(claim) + handler_raised = False try: try: state.handler(*args) except Exception: + handler_raised = True continue + except BaseException: + handler_raised = True + raise finally: deferred = _EVENT_DISPATCH_DEFERRED.get() _EVENT_DISPATCH_DEFERRED.reset(deferred_token) @@ -21616,12 +21993,16 @@ def _dispatch_event_handlers( _EVENT_DISPATCH_OWNER.reset(owner_token) _EVENT_DISPATCH_SEQUENCE.reset(sequence_token) if not deferred: + if handler_raised: + release_claim = getattr(args[0], "_release_handler_claim", None) if args else None + if callable(release_claim): + release_claim(claim) self._finish_event_handler(state, owner) + _EVENT_DIALOG_CLAIM.reset(claim_token) _EVENT_DIALOG_DISPATCH.reset(dialog_token) if dialog_dispatch is not None: dialog_dispatch.finish_if_idle() return activated - def _dismiss_dialog_if_unhandled(self, dialog: Dialog) -> None: with dialog._fallback_lock: if dialog._handled or dialog._fallback_attempted: @@ -21639,7 +22020,10 @@ def _dismiss_dialog_if_unhandled(self, dialog: Dialog) -> None: def _handle_dialog_event(self, payload: dict[str, Any]) -> None: dialog = Dialog(self, payload) self._dispatch_event_handlers("dialog", dialog) - self._dialog_dispatch_count += 1 + with self._event_dispatch_condition: + if dialog.type == "beforeunload": + self._beforeunload_dispatch_count += 1 + self._event_dispatch_condition.notify_all() def _snapshot_event_handlers( self, @@ -21762,6 +22146,16 @@ def remove_listener(self, event: str, f: Callable[..., Any]) -> None: if detach_worker_forwards: self._detach_worker_console_propagation() + def _ack_event_batch(self) -> bool: + ack_batch = getattr(self._event_stream, "ack_batch", None) + if not callable(ack_batch): + return True + try: + _call(ack_batch) + except Error: + return False + return True + def _event_pump(self) -> None: while self._event_listeners_active(): try: @@ -21769,13 +22163,19 @@ def _event_pump(self) -> None: except Error: break if not isinstance(batch, list): + if not self._ack_event_batch(): + break continue + stop_after_ack = False + terminal_consumed = False for envelope in batch: if not isinstance(envelope, dict): continue kind = str(envelope.get("kind") or "") if kind == "_closed": - return + terminal_consumed = True + stop_after_ack = True + break if kind == "_overflow": self._reconcile_event_stream_overflow(envelope.get("payload")) continue @@ -21790,7 +22190,13 @@ def _event_pump(self) -> None: event_sequence=event_sequence, ) if not self._event_listeners_active(): - return + stop_after_ack = True + break + if not self._ack_event_batch(): + break + self._note_event_batch_consumed(terminal_consumed) + if stop_after_ack: + return def _reconcile_event_stream_overflow(self, payload: Any) -> None: dropped = payload.get("dropped") if isinstance(payload, dict) else None @@ -21800,7 +22206,7 @@ def _reconcile_event_stream_overflow(self, payload: Any) -> None: dropped_count = 0 warnings.warn( f"rustwright page event stream overflow: dropped {dropped_count} event(s); " - "network-idle and dialog bookkeeping was resynchronized", + "network-idle bookkeeping was resynchronized", RuntimeWarning, stacklevel=2, ) @@ -21808,10 +22214,7 @@ def _reconcile_event_stream_overflow(self, payload: Any) -> None: self._network_idle_active_requests.clear() self._network_idle_last_activity = time.monotonic() self._network_idle_condition.notify_all() - try: - _call(self._core.handle_dialog, False, None, min(self._default_timeout, 500.0)) - except Error: - pass + # Retained dialog openings follow overflow and use ownership-aware dispatch. def _handle_observation_event( self, From 7ac5172bbb40270d730de378b83915bd66d6a9e8 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:52:33 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'src/'=20wi?= =?UTF-8?q?th=20remote=20'src/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/241 --- src/lib.rs | 2890 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 2506 insertions(+), 384 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9eddf5d..45c26b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -266,16 +266,27 @@ impl CloseOutcome { enum ClosePhase { Open, - Closing(watch::Sender>), - Closed(CloseOutcome), + Closing { + sender: watch::Sender>, + generation: u64, + before_unload_generation: Option, + }, + Closed { + outcome: CloseOutcome, + before_unload_generation: Option, + }, } struct CloseLifecycle { phase: Mutex, + next_generation: AtomicU64, } enum CloseStart { - Lead(watch::Sender>), + Lead { + sender: watch::Sender>, + generation: u64, + }, Wait(watch::Receiver>), Done(CloseOutcome), } @@ -284,34 +295,65 @@ impl CloseLifecycle { fn new() -> Self { Self { phase: Mutex::new(ClosePhase::Open), + next_generation: AtomicU64::new(0), } } fn start(&self) -> CloseStart { + self.start_with_before_unload(false) + } + + fn start_with_before_unload(&self, before_unload: bool) -> CloseStart { let mut phase = self.phase.lock().unwrap(); match &*phase { ClosePhase::Open => { + let generation = self + .next_generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); let (sender, _) = watch::channel(None); - *phase = ClosePhase::Closing(sender.clone()); - CloseStart::Lead(sender) + *phase = ClosePhase::Closing { + sender: sender.clone(), + generation, + before_unload_generation: before_unload.then_some(generation), + }; + CloseStart::Lead { sender, generation } } - ClosePhase::Closing(sender) => CloseStart::Wait(sender.subscribe()), - ClosePhase::Closed(outcome) => CloseStart::Done(outcome.clone()), + ClosePhase::Closing { sender, .. } => CloseStart::Wait(sender.subscribe()), + ClosePhase::Closed { outcome, .. } => CloseStart::Done(outcome.clone()), } } fn finish( &self, sender: watch::Sender>, + generation: u64, result: &RwResult<()>, close_on_error: bool, ) { let outcome = CloseOutcome::from_result(result); { let mut phase = self.phase.lock().unwrap(); + let Some(before_unload_generation) = (match &*phase { + ClosePhase::Closing { + generation: active_generation, + before_unload_generation, + .. + } if *active_generation == generation => Some(*before_unload_generation), + _ => None, + }) else { + sender.send_replace(Some(outcome)); + return; + }; if result.is_ok() || close_on_error { - *phase = ClosePhase::Closed(outcome.clone()); + *phase = ClosePhase::Closed { + outcome: outcome.clone(), + before_unload_generation, + }; } else { + // Clear the marker before reopening the phase. This keeps a + // failed close from changing the event policy of a later + // close generation. *phase = ClosePhase::Open; } } @@ -319,12 +361,41 @@ impl CloseLifecycle { } fn is_closed(&self) -> bool { - matches!(*self.phase.lock().unwrap(), ClosePhase::Closed(_)) + matches!(*self.phase.lock().unwrap(), ClosePhase::Closed { .. }) } fn is_closing_or_closed(&self) -> bool { !matches!(*self.phase.lock().unwrap(), ClosePhase::Open) } + + fn clear_before_unload(&self) { + let mut phase = self.phase.lock().unwrap(); + match &mut *phase { + ClosePhase::Closing { + before_unload_generation, + .. + } + | ClosePhase::Closed { + before_unload_generation, + .. + } => *before_unload_generation = None, + ClosePhase::Open => {} + } + } + + fn allows_events_during_close(&self) -> bool { + let phase = self.phase.lock().unwrap(); + matches!( + &*phase, + ClosePhase::Closing { + before_unload_generation: Some(_), + .. + } | ClosePhase::Closed { + before_unload_generation: Some(_), + .. + } + ) + } } async fn wait_for_close_outcome( @@ -350,12 +421,25 @@ where F: FnOnce() -> Fut, Fut: Future>, { - match lifecycle.start() { + single_flight_close_mode(lifecycle, close_on_error, false, cleanup).await +} + +async fn single_flight_close_mode( + lifecycle: Arc, + close_on_error: bool, + before_unload: bool, + cleanup: F, +) -> RwResult<()> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + match lifecycle.start_with_before_unload(before_unload) { CloseStart::Done(outcome) => outcome.into_result(), CloseStart::Wait(receiver) => wait_for_close_outcome(receiver).await, - CloseStart::Lead(sender) => { + CloseStart::Lead { sender, generation } => { let result = cleanup().await; - lifecycle.finish(sender, &result, close_on_error); + lifecycle.finish(sender, generation, &result, close_on_error); result } } @@ -2626,6 +2710,7 @@ fn py_err(error: RwError) -> PyErr { #[pyclass(name = "_RustFutureAbort")] struct PyRustFutureAbort { cancellation: RustFutureCancellation, + cancel_hook: Option, } #[cfg(feature = "python")] @@ -2747,6 +2832,9 @@ impl PyRustFutureAbort { fn __call__(&self, future: &Bound<'_, PyAny>) -> PyResult<()> { if future.call_method0("cancelled")?.extract::()? { self.cancellation.cancel(); + if let Some(hook) = self.cancel_hook.as_ref() { + hook(); + } } Ok(()) } @@ -2918,6 +3006,7 @@ where py, PyRustFutureAbort { cancellation: RustFutureCancellation::Tokio(task.abort_handle()), + cancel_hook: None, }, )?; if let Err(error) = returned @@ -2936,7 +3025,8 @@ fn python_future_on_with_delivery( handle: tokio::runtime::Handle, rust_future: F, convert: C, -) -> PyResult> + cancel_hook: Option, +) -> PyResult<(Py, tokio::task::AbortHandle)> where F: Future> + Send + 'static, T: Send + 'static, @@ -2950,10 +3040,12 @@ where let result = rust_future.await; settle_python_future_output(event_loop, future, result, convert); }); + let abort_handle = task.abort_handle(); let aborter = Py::new( py, PyRustFutureAbort { - cancellation: RustFutureCancellation::Tokio(task.abort_handle()), + cancellation: RustFutureCancellation::Tokio(abort_handle.clone()), + cancel_hook, }, )?; if let Err(error) = returned @@ -2963,7 +3055,7 @@ where task.abort(); return Err(error); } - Ok(returned) + Ok((returned, abort_handle)) } #[cfg(feature = "python")] @@ -2987,6 +3079,7 @@ where py, PyRustFutureAbort { cancellation: RustFutureCancellation::Thread(cancelled), + cancel_hook: None, }, )?; returned @@ -4150,6 +4243,244 @@ multiline-compatible = """4.5.6""" assert!(matches!(result, Err(RwError::Cancelled))); assert!(started.elapsed() < Duration::from_millis(250)); } + #[cfg(feature = "python")] + fn initialize_test_python() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let version = unsafe { std::ffi::CStr::from_ptr(pyo3::ffi::Py_GetVersion()) } + .to_str().unwrap(); + let version = version.split('.').take(2).collect::>().join("."); + let mut home = None; + #[cfg(unix)] + unsafe { + // Equal Python versions can come from incompatible distributions. Resolve + // the loaded library, not a PATH interpreter with different native modules. + let mut library: libc::Dl_info = std::mem::zeroed(); + if libc::dladdr(pyo3::ffi::Py_GetVersion as *const libc::c_void, &mut library) != 0 + && !library.dli_fname.is_null() { + let path = std::ffi::CStr::from_ptr(library.dli_fname).to_str().unwrap(); + home = Path::new(path).ancestors().find(|prefix| + prefix.join(format!("lib/python{version}/encodings")).is_dir() + ).map(Path::to_path_buf); + } + } + let home = home.unwrap_or_else(|| { + let executable = Path::new(env!("CARGO_MANIFEST_DIR")).join(".venv") + .join(if cfg!(windows) { "Scripts/python.exe" } else { "bin/python" }); + let python = if executable.is_file() { executable } else { + env::var_os("PYO3_PYTHON").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("python3")) + }; + let output = Command::new(python).args(["-c", + "import json, sys; print(json.dumps([sys.base_prefix, '%d.%d' % sys.version_info[:2]]))"]) + .env_remove("PYTHONHOME").env_remove("PYTHONPATH") + .output().expect("resolve the test interpreter home"); + assert!(output.status.success(), "test interpreter discovery failed"); + let config: Vec = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(version, config[1], "test interpreter version differs from linked Python"); + PathBuf::from(&config[0]) + }); + let home = std::ffi::CString::new(home.to_str().unwrap()).unwrap(); + // Configure the linked interpreter without changing process-wide environment. + // The stable ABI requires this wide string to live until interpreter shutdown. + unsafe { + let home = pyo3::ffi::Py_DecodeLocale(home.as_ptr(), std::ptr::null_mut()); + assert!(!home.is_null(), "decode the test interpreter home"); + pyo3::ffi::Py_SetPythonHome(home); + } + Python::initialize(); + }); + } + + #[cfg(feature = "python")] + pub(super) struct PageEventStreamHandoffBarrier { + pub entered: std::sync::mpsc::Sender<()>, + pub release: std::sync::mpsc::Receiver<()>, + pub restored: std::sync::mpsc::Sender<()>, + } + + #[cfg(feature = "python")] + pub(super) fn page_event_stream_handoff_barriers( + ) -> &'static Mutex> { + static BARRIERS: OnceLock>> = + OnceLock::new(); + BARRIERS.get_or_init(|| Mutex::new(HashMap::new())) + } + + #[cfg(feature = "python")] + #[test] + fn page_event_stream_cancel_retry_during_lease_handoff_preserves_receiver() { + initialize_test_python(); + let harness = navigation_test_harness(8); + let stream = PyPage { + inner: Arc::clone(&harness.page), + } + .combined_event_stream() + .unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (restored_tx, restored_rx) = std::sync::mpsc::channel(); + page_event_stream_handoff_barriers().lock().unwrap().insert( + Arc::as_ptr(&stream.state) as usize, + PageEventStreamHandoffBarrier { + entered: entered_tx, + release: release_rx, + restored: restored_tx, + }, + ); + Python::attach(|py| { + let asyncio = PyModule::import(py, "asyncio").unwrap(); + let event_loop = asyncio.call_method0("new_event_loop").unwrap(); + let events = asyncio.getattr("events").unwrap(); + events + .call_method1("_set_running_loop", (&event_loop,)) + .unwrap(); + let future = stream.wait_batch_async(py, Some(30_000.0), 64).unwrap(); + events + .call_method1("_set_running_loop", (py.None(),)) + .unwrap(); + future.call_method0(py, "cancel").unwrap(); + stream.cancel_wait(); + py.detach(move || entered_rx.recv_timeout(Duration::from_secs(5)).unwrap()); + // Park Drop at the former publication-before-restoration boundary. + // Both public acquisition paths must fail without stealing the receiver. + assert!(stream.receiver.lock().unwrap().is_none()); + assert!(stream.wait_batch_async(py, Some(1.0), 64).is_err()); + assert!(stream.wait_batch(py, Some(1.0), 64).is_err()); + events + .call_method1("_set_running_loop", (&event_loop,)) + .unwrap(); + let restored = stream.cancel_wait_async(py).unwrap(); + events + .call_method1("_set_running_loop", (py.None(),)) + .unwrap(); + assert!(!restored + .call_method0(py, "done") + .unwrap() + .extract::(py) + .unwrap()); + release_tx.send(()).unwrap(); + let bounded = asyncio.call_method1("wait_for", (restored, 5.0)).unwrap(); + event_loop + .call_method1("run_until_complete", (bounded,)) + .unwrap(); + py.detach(move || restored_rx.recv_timeout(Duration::from_secs(5)).unwrap()); + assert!(stream.receiver.lock().unwrap().is_some()); + assert!(stream.state.lock().unwrap().is_some()); + assert_eq!(stream.event_cursor(), 0); + // A failed second take must put back the receiver it already took. + let state = stream.state.lock().unwrap().take(); + assert!(stream.wait_batch_async(py, Some(1.0), 64).is_err()); + assert!(stream.receiver.lock().unwrap().is_some()); + assert!(stream.wait_batch(py, Some(1.0), 64).is_err()); + assert!(stream.receiver.lock().unwrap().is_some()); + *stream.state.lock().unwrap() = state; + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "alert", "message": "after cancellation"}}), + ); + events + .call_method1("_set_running_loop", (&event_loop,)) + .unwrap(); + let retry = stream.wait_batch_async(py, Some(100.0), 64).unwrap(); + events + .call_method1("_set_running_loop", (py.None(),)) + .unwrap(); + let batch: Value = serde_json::from_str( + &event_loop + .call_method1("run_until_complete", (retry,)) + .unwrap() + .extract::() + .unwrap(), + ) + .unwrap(); + stream.ack_batch(); + assert_eq!(batch[0]["kind"], "dialog"); + assert_eq!(batch[0]["payload"]["message"], "after cancellation"); + assert_eq!(stream.event_cursor(), 1); + event_loop + .call_method1("call_soon", (event_loop.getattr("stop").unwrap(),)) + .unwrap(); + event_loop.call_method0("run_forever").unwrap(); + event_loop.call_method0("close").unwrap(); + }); + } + + #[cfg(feature = "python")] + #[test] + fn page_event_stream_cancel_with_queued_python_settler_replays_batch() { + initialize_test_python(); + let harness = navigation_test_harness(8); + let stream = PyPage { + inner: Arc::clone(&harness.page), + } + .combined_event_stream() + .unwrap(); + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "alert", "message": "queued settlement"}}), + ); + Python::attach(|py| { + let asyncio = PyModule::import(py, "asyncio").unwrap(); + let event_loop = asyncio.call_method0("new_event_loop").unwrap(); + let events = asyncio.getattr("events").unwrap(); + events + .call_method1("_set_running_loop", (&event_loop,)) + .unwrap(); + let future = stream.wait_batch_async(py, Some(1_000.0), 64).unwrap(); + events + .call_method1("_set_running_loop", (py.None(),)) + .unwrap(); + let abort = stream + .active_wait + .lock() + .unwrap() + .as_ref() + .unwrap() + .abort_handle + .lock() + .unwrap() + .as_ref() + .unwrap() + .clone(); + py.detach(|| { + harness.page.browser.block_on_raw(async { + tokio::time::timeout(Duration::from_secs(5), async { + while !abort.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + }) + }); + // Native completion has queued the real settler, but Python has not + // executed it. Cancellation must return without waiting for that loop. + assert!(!future + .call_method0(py, "done") + .unwrap() + .extract::(py) + .unwrap()); + future.call_method0(py, "cancel").unwrap(); + stream.cancel_wait(); + events + .call_method1("_set_running_loop", (&event_loop,)) + .unwrap(); + let restored = stream.cancel_wait_async(py).unwrap(); + events + .call_method1("_set_running_loop", (py.None(),)) + .unwrap(); + let bounded = asyncio.call_method1("wait_for", (restored, 5.0)).unwrap(); + event_loop + .call_method1("run_until_complete", (bounded,)) + .unwrap(); + assert_eq!(stream.event_cursor(), 0); + let batch: Value = + serde_json::from_str(&stream.wait_batch(py, Some(100.0), 64).unwrap()).unwrap(); + assert_eq!(batch[0]["payload"]["message"], "queued settlement"); + assert_eq!(stream.event_cursor(), 1); + event_loop.call_method0("close").unwrap(); + }); + } #[test] fn cancel_and_physical_commit_have_one_atomic_winner() { @@ -9018,6 +9349,24 @@ multiline-compatible = """4.5.6""" #[test] fn blocking_wrapper_falls_back_when_python_is_unavailable() { + const CHILD: &str = "RUSTWRIGHT_TEST_WITHOUT_PYTHON"; + if env::var_os(CHILD).is_none() { + let output = Command::new(env::current_exe().unwrap()) + .args([ + "--exact", + "tests::blocking_wrapper_falls_back_when_python_is_unavailable", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } assert!( Python::try_attach(|_| ()).is_none(), "cargo test should not initialize the Python interpreter" @@ -9064,6 +9413,32 @@ multiline-compatible = """4.5.6""" assert!(lifecycle.is_closed()); } + #[test] + fn failed_before_unload_close_clears_marker_for_next_generation() { + let lifecycle = CloseLifecycle::new(); + let CloseStart::Lead { sender, generation } = lifecycle.start_with_before_unload(true) + else { + panic!("first close must own the lifecycle"); + }; + assert!(lifecycle.allows_events_during_close()); + lifecycle.finish( + sender, + generation, + &Err(RwError::Message("close failed".to_string())), + false, + ); + assert!(!lifecycle.is_closing_or_closed()); + assert!(!lifecycle.allows_events_during_close()); + + let CloseStart::Lead { sender, generation } = lifecycle.start_with_before_unload(false) + else { + panic!("failed close must allow a retry"); + }; + assert!(!lifecycle.allows_events_during_close()); + lifecycle.finish(sender, generation, &Ok(()), false); + assert!(lifecycle.is_closed()); + } + #[test] fn browser_operations_distinguish_intentional_close_from_transport_loss() { let make_browser = || { @@ -9109,11 +9484,14 @@ multiline-compatible = """4.5.6""" }; let (closed_browser, mut closed_writes) = make_browser(); - let sender = match closed_browser.inner.lifecycle.start() { - CloseStart::Lead(sender) => sender, + let (sender, generation) = match closed_browser.inner.lifecycle.start() { + CloseStart::Lead { sender, generation } => (sender, generation), _ => panic!("new lifecycle must lead close"), }; - closed_browser.inner.lifecycle.finish(sender, &Ok(()), true); + closed_browser + .inner + .lifecycle + .finish(sender, generation, &Ok(()), true); assert!(matches!(closed_browser.new_page(), Err(RwError::Closed))); assert!(matches!( closed_browser.pages(Duration::from_secs(1)), @@ -9960,6 +10338,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests: Arc::clone(&working_requests), page: None, + restored: None, }; drop(lease); @@ -9987,6 +10366,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests, page: None, + restored: None, }; { let mut live = shared_requests.lock().unwrap(); @@ -10086,6 +10466,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests, page: Some(page.clone()), + restored: None, }; page.release_memory_buffers(); @@ -10201,6 +10582,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests, page: None, + restored: None, }; shared_requests.lock().unwrap().reset_after_overflow(20); @@ -10368,6 +10750,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests, page: None, + restored: None, }; shared_requests.lock().unwrap().reset_after_overflow(8_000); @@ -10465,6 +10848,7 @@ multiline-compatible = """4.5.6""" requests: Arc::clone(&shared_requests), working_requests, page: None, + restored: None, }; shared_requests.lock().unwrap().reset_after_overflow(8_000); @@ -10583,6 +10967,729 @@ multiline-compatible = """4.5.6""" assert_eq!(batch[2]["kind"], "requestfinished"); } + pub(super) fn page_event_stream_snapshot_barriers( + ) -> &'static Mutex, oneshot::Receiver<()>)>> { + static BARRIERS: OnceLock< + Mutex, oneshot::Receiver<()>)>>, + > = OnceLock::new(); + BARRIERS.get_or_init(|| Mutex::new(HashMap::new())) + } + + #[tokio::test] + async fn beforeunload_stream_drains_dialog_before_terminal_receipt() { + for terminal in [ + json!({"method": "Target.targetDestroyed", "params": {"targetId": "test-target"}}), + json!({"method": "Inspector.detached", "sessionId": "page-session", "params": {}}), + json!({"method": "Target.detachedFromTarget", "params": {"sessionId": "page-session"}}), + ] { + for (with_dialog, pending_response, after_snapshot, max_events) in + [false, true].into_iter().flat_map(|dialog| { + [false, true].into_iter().flat_map(move |response| { + [false, true].into_iter().flat_map(move |after| { + [1, 2, 64] + .into_iter() + .map(move |limit| (dialog, response, after, limit)) + }) + }) + }) + { + let mut harness = navigation_test_harness(8); + let page = Arc::clone(&harness.page); + let (mut events, mut cursor, mut state) = + PageEventStreamState::subscribe_page_event_stream(&page); + let closing_page = Arc::clone(&page); + let closing = + tokio::spawn(page_close_async(closing_page, Duration::from_secs(2), true)); + let command = harness.next_command("Page.close").await; + harness.reply(&command, json!({})); + closing.await.unwrap().unwrap(); + assert!(page.lifecycle.is_closing_or_closed()); + assert!(!page.target_closed.load(Ordering::SeqCst)); + #[cfg(feature = "python")] + assert!(!PyPage { + inner: Arc::clone(&page) + } + .is_closed()); + let publish = async { + if pending_response { + harness.emit(json!({"sessionId": "page-session", "method": "Network.requestWillBeSent", + "params": {"requestId": "pending", "type": "Document", + "request": {"url": "https://example.test/", "method": "GET", "headers": {}}}})); + harness.emit(json!({"sessionId": "page-session", "method": "Network.responseReceived", + "params": {"requestId": "pending", "type": "Document", "hasExtraInfo": true, + "response": {"url": "https://example.test/", "status": 200, "headers": {}}}})); + } + if with_dialog { + harness.emit(json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "beforeunload", "message": ""}})); + } + harness.emit(terminal.clone()); + handle_page_oopif_event(Arc::clone(&page), terminal.clone()).await; + assert!(page.target_closed.load(Ordering::SeqCst)); + }; + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let first = wait_for_page_event_batch( + &mut events, + Arc::clone(&harness.event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx.clone(), + alive_rx.clone(), + Duration::ZERO, + max_events, + ); + let (mut batch, mut closed) = if after_snapshot { + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + page_event_stream_snapshot_barriers() + .lock() + .unwrap() + .insert(Arc::as_ptr(&page) as usize, (entered_tx, release_rx)); + // Publish only after the reader captured an empty tail. The + // terminal flag must force another ordered scan of that tail. + let (_, result) = tokio::join!( + async { + entered_rx.await.unwrap(); + publish.await; + release_tx.send(()).unwrap(); + }, + first + ); + result + } else { + publish.await; + first.await + }; + assert!(batch.len() <= max_events); + for _ in 0..4 { + if closed { + break; + } + let (next, terminal) = wait_for_page_event_batch( + &mut events, + Arc::clone(&harness.event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + &page, + close_rx.clone(), + alive_rx.clone(), + Duration::ZERO, + max_events, + ) + .await; + assert!(!next.is_empty()); + assert!(next.len() <= max_events); + batch.extend(next); + closed = terminal; + } + assert!(closed); + let kinds: Vec<_> = batch + .iter() + .map(|event| event["kind"].as_str().unwrap()) + .collect(); + let mut expected = Vec::new(); + if pending_response { + expected.extend(["request", "response"]); + } + if with_dialog { + expected.push("dialog"); + } + expected.push("_closed"); + assert_eq!(kinds, expected); + assert!(batch + .windows(2) + .all(|pair| pair[0]["seq"].as_u64() < pair[1]["seq"].as_u64())); + assert!(state.network.pending_responses.is_empty()); + assert!(state.ready.is_empty()); + assert_eq!(cursor, harness.event_log.lock().unwrap().cursor()); + } + } + } + + #[cfg(feature = "python")] + #[test] + fn page_event_stream_sync_beforeunload_batches_share_terminal_read_cursor() { + initialize_test_python(); + let harness = navigation_test_harness(8); + let page = &harness.page; + let stream = PyPage { + inner: Arc::clone(page), + } + .combined_event_stream() + .unwrap(); + let _closing = page.lifecycle.start_with_before_unload(true); + harness.emit( + json!({"sessionId": "page-session", "method": "Network.requestWillBeSent", + "params": {"requestId": "pending", "type": "Document", + "request": {"url": "https://example.test/", "method": "GET", "headers": {}}}}), + ); + harness.emit( + json!({"sessionId": "page-session", "method": "Network.responseReceived", + "params": {"requestId": "pending", "type": "Document", "hasExtraInfo": true, + "response": {"url": "https://example.test/", "status": 200, "headers": {}}}}), + ); + { + let entries = harness.event_log.lock().unwrap().entries_since(0); + let mut slot = stream.state.lock().unwrap(); + let state = slot.as_mut().unwrap(); + for (seq, event) in entries { + process_page_observation_event_with_page( + seq, + &event, + &harness.event_log, + "page-session", + &page.network_requests, + state, + Some(page), + ); + } + // This pin tests terminal draining, independent of time spent waiting for the GIL. + state + .network + .pending_responses + .get_mut("pending") + .unwrap() + .deadline = tokio::time::Instant::now() + DISABLED_ACTION_TIMEOUT; + *stream.cursor.lock().unwrap() = harness.event_log.lock().unwrap().cursor(); + } + for index in 0..64 { + harness.emit(json!({"sessionId": "page-session", "method": "Runtime.consoleAPICalled", + "params": {"type": "log", "args": [{"type": "string", "value": index.to_string()}]}})); + } + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "beforeunload", "message": "retained"}}), + ); + harness.emit( + json!({"method": "Target.targetDestroyed", "params": {"targetId": "test-target"}}), + ); + page.target_closed.store(true, Ordering::SeqCst); + Python::attach(|py| { + let first: Vec = + serde_json::from_str(&stream.wait_batch(py, Some(100.0), 64).unwrap()).unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0]["kind"], "request"); + stream.ack_batch(); + let full: Vec = + serde_json::from_str(&stream.wait_batch(py, Some(100.0), 64).unwrap()).unwrap(); + assert_eq!(full.len(), 64); + assert_eq!(full[0]["kind"], "response"); + assert!(full[1..].iter().all(|event| event["kind"] == "console")); + let terminal_cursor = stream.event_cursor(); + let log_cursor = harness.event_log.lock().unwrap().cursor(); + assert_eq!(terminal_cursor, log_cursor); + assert!(!stream.is_closed()); + stream.ack_batch(); + let last: Vec = + serde_json::from_str(&stream.wait_batch(py, Some(100.0), 64).unwrap()).unwrap(); + assert_eq!( + last.iter() + .map(|event| event["kind"].as_str().unwrap()) + .collect::>(), + ["console", "dialog", "_closed"] + ); + assert_eq!(stream.event_cursor(), terminal_cursor); + // Native closure acknowledges reading, not Python dispatch. The + // close barrier must wait for consumption even at this same cursor. + assert!(stream.is_closed()); + }); + } + + #[tokio::test] + async fn beforeunload_stream_overflow_drains_retained_dialog_and_terminal() { + for max_events in [1, 64] { + let harness = navigation_test_harness(8); + let page = &harness.page; + let (mut events, mut cursor, mut state) = + PageEventStreamState::subscribe_page_event_stream(page); + for index in 0..CDP_EVENT_LOG_LIMIT { + harness.emit(json!({"method": "Test.unrelated", "params": {"index": index}})); + } + let _closing = page.lifecycle.start_with_before_unload(true); + let dialog_seq = harness.event_log.lock().unwrap().cursor(); + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "beforeunload", "message": "retained"}}), + ); + let terminal = + json!({"method": "Target.targetDestroyed", "params": {"targetId": "test-target"}}); + harness.emit(terminal.clone()); + handle_page_oopif_event(Arc::clone(page), terminal).await; + let oldest_seq = harness.event_log.lock().unwrap().oldest_seq(); + assert!(oldest_seq > cursor && oldest_seq < dialog_seq); + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + let mut received = Vec::new(); + let mut closed = false; + for _ in 0..3 { + let (batch, terminal) = wait_for_page_event_batch( + &mut events, + Arc::clone(&harness.event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + page, + close_rx.clone(), + alive_rx.clone(), + Duration::from_millis(100), + max_events, + ) + .await; + assert!(batch.len() <= max_events); + received.extend(batch); + closed = terminal; + if closed { + break; + } + } + assert!(closed); + assert_eq!( + received + .iter() + .map(|event| event["kind"].as_str().unwrap()) + .collect::>(), + ["_overflow", "dialog", "_closed"] + ); + assert_eq!(received[0]["payload"]["dropped"], oldest_seq); + assert_eq!(received[1]["seq"], dialog_seq); + assert_eq!(received[1]["payload"]["message"], "retained"); + assert_eq!(cursor, harness.event_log.lock().unwrap().cursor()); + } + } + + #[tokio::test] + async fn evicted_active_dialog_recovery_respects_delivery_and_close() { + for max_events in [1, 64] { + for disposition in ["open", "delivered", "closed", "detached", "released"] { + let harness = navigation_test_harness(8); + let page = &harness.page; + let (mut events, mut cursor, mut state) = + PageEventStreamState::subscribe_page_event_stream(page); + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "beforeunload", "message": "evicted"}}), + ); + let (_close_tx, close_rx) = watch::channel(false); + let (_alive_tx, alive_rx) = watch::channel(true); + if disposition == "delivered" { + let (batch, _) = wait_for_page_event_batch( + &mut events, + Arc::clone(&harness.event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + page, + close_rx.clone(), + alive_rx.clone(), + Duration::ZERO, + max_events, + ) + .await; + assert_eq!(batch[0]["kind"], "dialog"); + } else if disposition == "closed" { + harness.emit(json!({"sessionId": "page-session", "method": "Page.javascriptDialogClosed", "params": {}})); + } else if disposition == "detached" { + harness.emit(json!({"method": "Target.detachedFromTarget", "params": {"sessionId": "page-session"}})); + } else if disposition == "released" { + page.release_memory_buffers(); + } + for _ in 0..=CDP_EVENT_LOG_LIMIT { + harness.emit(json!({"sessionId": "other-session", "method": "Test.unrelated"})); + } + assert!(harness.event_log.lock().unwrap().oldest_seq() > cursor); + assert_eq!( + harness.event_log.lock().unwrap().active_dialogs.len(), + usize::from(matches!(disposition, "open" | "delivered")) + ); + let mut received = Vec::new(); + for _ in 0..3 { + let (batch, _) = wait_for_page_event_batch( + &mut events, + Arc::clone(&harness.event_log), + &mut cursor, + "page-session", + Arc::clone(&page.network_requests), + &mut state, + page, + close_rx.clone(), + alive_rx.clone(), + Duration::ZERO, + max_events, + ) + .await; + assert!(batch.len() <= max_events); + received.extend(batch); + } + assert_eq!( + received + .iter() + .filter(|event| event["kind"] == "dialog") + .count(), + usize::from(disposition == "open") + ); + harness.event_log.lock().unwrap().clear_retained(); + assert!(harness.event_log.lock().unwrap().active_dialogs.is_empty()); + } + } + } + + #[test] + fn page_drop_tolerates_event_log_poison_during_unwind() { + let harness = navigation_test_harness(8); + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "alert", "message": "cleanup"}}), + ); + let log = Arc::clone(&harness.event_log); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let harness = harness; + let _guard = harness.event_log.lock().unwrap(); + panic!("simulate an assertion while holding the event log"); + })); + assert!(result.is_err()); + assert!(log.is_poisoned()); + assert!(log + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_dialogs + .is_empty()); + } + + #[test] + fn reset_active_dialog_recovery_respects_delivery_and_close() { + for backfill in [false, true] { + for disposition in ["queued", "delivered", "closed"] { + let harness = navigation_test_harness(8); + let page = &harness.page; + let mut state = PageEventStreamState::for_page(page); + harness.emit(json!({"sessionId": "page-session", "method": "Network.responseReceived", + "params": {"requestId": "pending", "hasExtraInfo": true, "response": {"status": 200}}})); + harness.emit( + json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": "beforeunload", "message": "queued"}}), + ); + let entries = harness.event_log.lock().unwrap().entries_since(0); + for (seq, event) in entries { + process_page_observation_event_with_page( + seq, + &event, + &harness.event_log, + "page-session", + &page.network_requests, + &mut state, + Some(page), + ); + } + let mut batch = Vec::new(); + append_ready_page_events(&mut batch, &mut state, 64); + assert!(batch.is_empty()); + assert_eq!(state.ready[&1]["kind"], "dialog"); + assert_eq!(state.network.earliest_pending_seq(), Some(0)); + if disposition == "delivered" { + state.network.pending_responses.clear(); + append_ready_page_events(&mut batch, &mut state, 64); + assert_eq!(batch[0]["kind"], "dialog"); + } else if disposition == "closed" { + harness.emit(json!({"sessionId": "page-session", "method": "Page.javascriptDialogClosed", "params": {}})); + } + for _ in 0..2 { + let tombstone_seq = harness.event_log.lock().unwrap().cursor(); + harness.emit(json!({"sessionId": "other-session", "method": "Runtime.consoleAPICalled", + "params": {"type": "log", "args": (0..9).map(|index| + json!({"type": "string", "value": format!("{index}:{}", "x".repeat(8_193))}) + ).collect::>()}})); + if backfill { + // The request store must backfill the real tombstone. + page.network_requests.lock().unwrap().next_applied_seq = tombstone_seq; + harness.emit( + json!({"sessionId": "other-session", "method": "Test.unrelated"}), + ); + } + let (seq, event) = { + let log = harness.event_log.lock().unwrap(); + assert_eq!(log.oldest_seq(), 0); + let entries = log.entries_since(tombstone_seq); + assert!(is_unreplayable_cdp_event(&entries[0].1)); + entries.last().unwrap().clone() + }; + process_page_observation_event_with_page( + seq, + &event, + &harness.event_log, + "page-session", + &page.network_requests, + &mut state, + Some(page), + ); + assert!(state.network.pending_responses.is_empty()); + } + batch.clear(); + append_ready_page_events(&mut batch, &mut state, 64); + assert_eq!( + batch + .iter() + .filter(|event| event["kind"] == "dialog") + .count(), + usize::from(disposition == "queued") + ); + assert!(batch.iter().any(|event| event["kind"] == "_overflow")); + } + } + } + + #[cfg(feature = "python")] + #[test] + fn evicted_active_dialog_reaches_both_python_pumps_once() { + initialize_test_python(); + Python::attach(|py| { + let sys = PyModule::import(py, "sys").unwrap(); + sys.getattr("path") + .unwrap() + .call_method1( + "insert", + (0, concat!(env!("CARGO_MANIFEST_DIR"), "/python")), + ) + .unwrap(); + let native = PyModule::new(py, "rustwright._rustwright").unwrap(); + _rustwright(py, &native).unwrap(); + sys.getattr("modules") + .unwrap() + .set_item("rustwright._rustwright", native) + .unwrap(); + let source = + std::ffi::CString::new(include_str!("../tests/dialog_overflow_pump.py")).unwrap(); + let helper = PyModule::from_code( + py, + &source, + c"dialog_overflow_pump.py", + c"dialog_overflow_pump", + ) + .unwrap(); + for async_mode in [false, true] { + for ownership in ["unowned", "listener", "waiter"] { + for (kind, tombstone) in [ + ("alert", false), + ("beforeunload", false), + ("alert", true), + ("beforeunload", true), + ] { + let mut harness = navigation_test_harness(8); + let stream = PyPage { + inner: Arc::clone(&harness.page), + } + .combined_event_stream() + .unwrap(); + let event_log = Arc::clone(&harness.event_log); + let events = harness.events.clone(); + let pending = Arc::clone(&harness.pending); + let publish = pyo3::types::PyCFunction::new_closure(py, None, None, + move |args: &Bound<'_, pyo3::types::PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>| -> PyResult<()> { + let phase: u8 = args.get_item(0)?.extract()?; + let emit = |event| dispatch_cdp_payload(event, Arc::clone(&pending), events.clone(), Arc::clone(&event_log)); + let overflow = || { + if tombstone { + emit(json!({"sessionId": "other-session", "method": "Runtime.consoleAPICalled", + "params": {"type": "log", "args": (0..9).map(|index| + json!({"type": "string", "value": format!("{index}:{}", "x".repeat(8_193))}) + ).collect::>()}})); + let log = event_log.lock().unwrap(); + assert_eq!(log.oldest_seq(), 0); + assert!(is_unreplayable_cdp_event(&log.events.back().unwrap().event)); + drop(log); + // Async request snapshots backfill the tombstone on the + // next event. Keep that reset ahead of the pump's marker. + emit(json!({"sessionId": "other-session", "method": "Test.unrelated"})); + } else { + for _ in 0..=CDP_EVENT_LOG_LIMIT { + emit(json!({"sessionId": "other-session", "method": "Test.unrelated"})); + } + } + }; + if phase == 1 { + if tombstone { + emit(json!({"sessionId": "page-session", "method": "Network.responseReceived", + "params": {"requestId": "pending", "hasExtraInfo": true, + "response": {"status": 200}}})); + } + let opening = event_log.lock().unwrap().cursor(); + emit(json!({"sessionId": "page-session", "method": "Page.javascriptDialogOpening", + "params": {"type": kind, "message": "evicted"}})); + overflow(); + let log = event_log.lock().unwrap(); + assert_eq!(log.oldest_seq() > opening, !tombstone); + assert_eq!(log.entries_since(0).iter().any(|(_, event)| event["method"] == "Page.javascriptDialogOpening"), tombstone); + } else if phase == 2 { + overflow(); + } + emit(json!({"sessionId": "page-session", "method": "Runtime.consoleAPICalled", + "params": {"type": "log", "args": [{"type": "string", "value": if phase == 0 { "blocked" } else { "drained" }}]}})); + Ok(()) + }).unwrap(); + helper + .getattr("exercise_dialog_overflow_pump") + .unwrap() + .call1((Py::new(py, stream).unwrap(), publish, async_mode, ownership, + if async_mode && tombstone { 4 } else { 2 })) + .unwrap_or_else(|error| { + error.print(py); + panic!("dialog overflow pump failed: async={async_mode}, ownership={ownership}, kind={kind}, tombstone={tombstone}"); + }); + assert!( + harness.write_rx.try_recv().is_err(), + "native recovery must issue no CDP command" + ); + } + } + } + }); + } + + #[tokio::test] + async fn console_capture_lock_waits_respect_operation_budget() { + for lock_site in [ + "setup", + "prepare", + "enable", + "iframe_detach", + "worker_detach", + ] { + let mut harness = navigation_test_harness(8); + if matches!(lock_site, "iframe_detach" | "worker_detach") { + harness.emit(json!({ + "sessionId": "page-session", + "method": "Target.attachedToTarget", + "params": { + "sessionId": "detached-session", + "targetInfo": { + "type": if lock_site == "iframe_detach" { "iframe" } else { "worker" }, + "targetId": "detached-target" + } + } + })); + harness.emit(json!({ + "method": "Target.detachedFromTarget", + "params": {"sessionId": "detached-session"} + })); + } + let page = &harness.page; + let setup_guard = page.console_capture.setup_lock.lock().await; + let enabled_guard = page.console_capture.enabled_sessions.lock().await; + let budget = Duration::from_millis(100); + let started = Instant::now(); + let result = tokio::time::timeout(Duration::from_millis(250), async { + match lock_site { + "setup" => enable_console_capture(page, budget).await.map(|_| ()), + "prepare" | "iframe_detach" | "worker_detach" => { + prepare_console_capture(page, OperationDeadline::new(budget)) + .await + .map(|_| ()) + } + _ => enable_console_capture_for_session_at_sample( + page, + "page-session", + budget, + None, + ) + .await + .map(|_| ()), + } + }) + .await + .expect("console setup lock exceeded the operation budget"); + assert!( + matches!(result, Err(RwError::Timeout(100))), + "{lock_site}: {result:?}" + ); + // Allow scheduler overhead, but never wait for the lock holder. + assert!(started.elapsed() < Duration::from_millis(200)); + drop(enabled_guard); + drop(setup_guard); + assert!(harness.try_next_command_any().is_none()); + } + } + + #[tokio::test] + async fn console_replay_sampler_uses_isolated_clock_and_rejects_thrown_values() { + for throws in [false, true] { + let mut harness = navigation_test_harness(8); + warm_main_frame_cache(&harness.page); + let page = Arc::clone(&harness.page); + let sample = tokio::spawn(async move { + sample_console_replay_clock_for_page( + &page, + "page-session", + OperationDeadline::new(Duration::from_secs(2)), + ) + .await + }); + let world = harness.next_command("Page.createIsolatedWorld").await; + harness.reply(&world, json!({"executionContextId": 7})); + let evaluate = harness.next_command("Runtime.evaluate").await; + // An application-world evaluation would return a poisoned clock. + let isolated = evaluate["params"]["contextId"] == 7; + let value = if isolated && !throws { + 100.0 + } else { + 9_000_000_000_000_000.0 + }; + let response = if throws { + json!({"result": {"type": "number", "value": value}, "exceptionDetails": {"text": "thrown clock"}}) + } else { + json!({"result": {"type": "number", "value": value}}) + }; + harness.reply(&evaluate, response); + assert_eq!( + sample.await.unwrap(), + if throws { None } else { Some(100.0) } + ); + assert!( + isolated, + "application Date overrides must never supply the sample" + ); + } + } + + #[tokio::test] + async fn console_replay_untrusted_sessions_do_not_sample_application_clock() { + let mut harness = navigation_test_harness(8); + warm_main_frame_cache(&harness.page); + harness + .page + .console_capture + .enabled_sessions + .lock() + .await + .insert("page-session".to_string()); + harness + .page + .frame_state + .lock() + .unwrap() + .record_worker_session_at_sequence("worker-session", Some(0)); + let deadline = OperationDeadline::new(Duration::from_secs(2)); + let samples = prepare_console_capture(&harness.page, deadline) + .await + .unwrap(); + assert_eq!(samples.get("worker-session"), Some(&None)); + assert_eq!( + sample_console_replay_clock_for_page(&harness.page, "unresolved-session", deadline) + .await, + None + ); + assert!( + harness.try_next_command_any().is_none(), + "untrusted sessions must not evaluate default-world Date" + ); + } + #[tokio::test] async fn console_capture_provisional_cutoff_blocks_ordered_pump_during_setup() { let mut harness = navigation_test_harness(16); @@ -10594,6 +11701,11 @@ multiline-compatible = """4.5.6""" let mut state = PageEventStreamState::for_page(&page); let mut cursor = 0; + page.frame_state + .lock() + .unwrap() + .session_frames + .insert("child-session".to_string(), "child-frame".to_string()); let capture_page = Arc::clone(&page); let capture = tokio::spawn(async move { enable_console_capture_for_session( @@ -10603,6 +11715,15 @@ multiline-compatible = """4.5.6""" ) .await }); + let world = harness.next_command("Page.createIsolatedWorld").await; + harness.reply(&world, json!({"executionContextId": 7})); + let sample = harness.next_command("Runtime.evaluate").await; + assert_eq!(sample["params"]["contextId"], 7); + assert_eq!(sample["params"]["expression"], "Date.now()"); + harness.reply( + &sample, + json!({"result": {"type": "number", "value": 100.0}}), + ); let enable = harness.next_command("Runtime.enable").await; assert_eq!( event_log @@ -10636,6 +11757,7 @@ multiline-compatible = """4.5.6""" "sessionId": "child-session", "method": "Runtime.consoleAPICalled", "params": { + "timestamp": 99.0, "type": "log", "args": [{"type": "string", "value": "before capture response"}] } @@ -10695,6 +11817,11 @@ multiline-compatible = """4.5.6""" let mut state = PageEventStreamState::for_page(&page); let mut cursor = 0; + page.frame_state + .lock() + .unwrap() + .session_frames + .insert("child-session".to_string(), "child-frame".to_string()); let capture_page = Arc::clone(&page); let capture = tokio::spawn(async move { enable_console_capture_for_session( @@ -10704,6 +11831,15 @@ multiline-compatible = """4.5.6""" ) .await }); + let world = harness.next_command("Page.createIsolatedWorld").await; + harness.reply(&world, json!({"executionContextId": 7})); + let sample = harness.next_command("Runtime.evaluate").await; + assert_eq!(sample["params"]["contextId"], 7); + assert_eq!(sample["params"]["expression"], "Date.now()"); + harness.reply( + &sample, + json!({"result": {"type": "number", "value": 100.0}}), + ); let enable = harness.next_command("Runtime.enable").await; assert_eq!( event_log @@ -10775,6 +11911,19 @@ multiline-compatible = """4.5.6""" ) .await }); + let frame_tree = harness.next_command("Page.getFrameTree").await; + harness.reply( + &frame_tree, + json!({"frameTree": {"frame": {"id": "main-frame"}}}), + ); + let create_world = harness.next_command("Page.createIsolatedWorld").await; + harness.reply(&create_world, json!({"executionContextId": 1})); + let sample = harness.next_command("Runtime.evaluate").await; + assert_eq!(sample["params"]["expression"], "Date.now()"); + harness.reply( + &sample, + json!({"result": {"type": "number", "value": 100.0}}), + ); let enable = harness.next_command("Runtime.enable").await; page.release_memory_buffers(); harness.reply(&enable, json!({})); @@ -12077,6 +13226,15 @@ return this.dataset.mainWorldOverride === "observed"; }; let responder = async move { let mut methods = Vec::new(); + let create_world = harness.next_command("Page.createIsolatedWorld").await; + methods.push("Page.createIsolatedWorld"); + harness.reply(&create_world, json!({"executionContextId": 1})); + let sample = harness.next_command("Runtime.evaluate").await; + assert_eq!(sample["params"]["expression"], "Date.now()"); + harness.reply( + &sample, + json!({"result": {"type": "number", "value": 100.0}}), + ); let runtime_enable = harness.next_command("Runtime.enable").await; methods.push("Runtime.enable"); assert_eq!(runtime_enable["sessionId"], "page-session"); @@ -12118,6 +13276,7 @@ return this.dataset.mainWorldOverride === "observed"; assert_eq!( methods, [ + "Page.createIsolatedWorld", "Runtime.enable", "Runtime.evaluate", "Runtime.callFunctionOn", @@ -13724,7 +14883,7 @@ return this.dataset.mainWorldOverride === "observed"; } #[tokio::test] - async fn console_capture_multi_session_setup_uses_remaining_deadline() { + async fn console_capture_multi_session_setup_samples_before_enable() { let mut harness = navigation_test_harness(8); { let mut state = harness.page.frame_state.lock().unwrap(); @@ -13734,35 +14893,55 @@ return this.dataset.mainWorldOverride === "observed"; .insert("iframe-session".to_string()); } let page = Arc::clone(&harness.page); - let operation = - async move { enable_console_capture(&page, Duration::from_millis(100)).await }; - let responder = async { + let operation_page = Arc::clone(&page); + let operation = enable_console_capture(&operation_page, Duration::from_secs(1)); + let responder_page = Arc::clone(&page); + let responder = async move { + let mut sampled = 0; + while sampled < 2 { + let command = harness.next_command_any().await; + match command["method"].as_str() { + Some("Page.getFrameTree") => harness.reply( + &command, + json!({"frameTree": {"frame": {"id": "main-frame"}}}), + ), + Some("Page.createIsolatedWorld") => { + harness.reply(&command, json!({"executionContextId": sampled + 1})); + let sample = harness.next_command("Runtime.evaluate").await; + assert_eq!(sample["params"]["expression"], "Date.now()"); + harness.reply( + &sample, + json!({"result": {"type": "number", "value": 100.0 + sampled as f64 * 100.0}}), + ); + sampled += 1; + } + Some("Runtime.evaluate") => { + assert_eq!(command["params"]["expression"], "Date.now()"); + harness.reply( + &command, + json!({"result": {"type": "number", "value": 100.0 + sampled as f64 * 100.0}}), + ); + sampled += 1; + } + method => panic!("unexpected console sample command: {method:?}"), + } + } let first = harness.next_command("Runtime.enable").await; - tokio::time::sleep(Duration::from_millis(60)).await; harness.reply(&first, json!({})); + responder_page + .observation_event_cursor + .store(u64::MAX, Ordering::SeqCst); let second = harness.next_command("Runtime.enable").await; + harness.reply(&second, json!({})); + responder_page + .observation_event_cursor + .store(u64::MAX, Ordering::SeqCst); (first["sessionId"].clone(), second["sessionId"].clone()) }; - let started = Instant::now(); - let (result, (first_session, second_session)) = - tokio::time::timeout(Duration::from_millis(135), async { - tokio::join!(operation, responder) - }) - .await - .expect("second session setup must consume the remaining budget"); - - assert!( - matches!(result, Err(RwError::Timeout(ms)) if ms > 0 && ms < 100), - "second setup must report only its remaining timeout: {result:?}" - ); + let (result, (first_session, second_session)) = tokio::join!(operation, responder); + result.expect("multi-session capture should complete"); assert_ne!(first_session, second_session); - assert!( - started.elapsed() < Duration::from_millis(135), - "second setup exceeded the original deadline: {:?}", - started.elapsed() - ); } - #[tokio::test] async fn sequence_gate_live_iframe_attachments_without_parent_mapping_are_noops() { let mut harness = navigation_test_harness(16); @@ -22951,6 +24130,234 @@ return this.dataset.mainWorldOverride === "observed"; assert!(log.retained_bytes <= CDP_EVENT_LOG_MAX_BYTES); assert_eq!(log.cursor_after_event(0, &live), 1); } + + #[test] + fn console_replay_sample_filters_by_browser_timestamp() { + let mut log = CdpEventLog::new(); + let generation = log.begin_console_replay_cutoff("page-session", Some(100.0)); + let replay = log.push(json!({ + "sessionId": "page-session", + "method": "Runtime.consoleAPICalled", + "params": { + "timestamp": 99.0, + "type": "log", + "args": [{"type": "string", "value": "replayed"}] + } + })); + let equal = log.push(json!({ + "sessionId": "page-session", + "method": "Runtime.consoleAPICalled", + "params": { + "timestamp": 100.0, + "type": "log", + "args": [{"type": "string", "value": "live-equal"}] + } + })); + let cutoff = log.cursor(); + log.finalize_console_replay_cutoff("page-session", generation, cutoff); + let event_log = Arc::new(Mutex::new(log)); + let cutoffs = HashMap::from([("page-session".to_string(), cutoff)]); + let samples = HashMap::from([("page-session".to_string(), Some(100.0))]); + assert!(console_event_is_replay_history( + &replay, &event_log, &cutoffs, &samples + )); + assert!(!console_event_is_replay_history( + &equal, &event_log, &cutoffs, &samples + )); + } + #[tokio::test] + async fn console_replay_overlapping_windows_preserve_page_stream_and_waiter_events() { + let event_log = Arc::new(Mutex::new(CdpEventLog::new())); + let mut state = PageEventStreamState::for_session("page-session".to_string()); + let requests = Arc::new(Mutex::new(NetworkRequestStore::new(0))); + for method in ["Runtime.consoleAPICalled", "Runtime.exceptionThrown"] { + let (first_event, second_event, first_cutoff) = { + let mut log = event_log.lock().unwrap(); + let first = log.begin_console_replay_cutoff("page-session", Some(100.0)); + let first_event = log.push(json!({"sessionId": "page-session", "method": method, + "params": {"timestamp": 150.0, "type": "log", "args": [{"type": "string", "value": "live"}], + "exceptionDetails": {"text": "live"}}})); + let cutoff = log.cursor(); + // Leave generation one provisional until generation two has begun. + let second = log.begin_console_replay_cutoff("page-session", Some(200.0)); + let second_event = log.push(json!({"sessionId": "page-session", "method": method, + "params": {"timestamp": 150.0, "type": "log", "args": [{"type": "string", "value": "history"}], + "exceptionDetails": {"text": "history"}}})); + let end = log.cursor(); + log.finalize_console_replay_cutoff("page-session", second, end); + log.finalize_console_replay_cutoff("page-session", first, end); + (first_event, second_event, cutoff) + }; + for event in [&first_event, &second_event] { + process_page_observation_event( + cdp_event_sequence(event).unwrap(), + event, + &event_log, + "page-session", + &requests, + &mut state, + ); + } + let mut batch = Vec::new(); + append_ready_page_events(&mut batch, &mut state, 64); + assert_eq!( + batch.len(), + 1, + "queued live event must survive the newer sample" + ); + assert_eq!(batch[0]["seq"], cdp_event_sequence(&first_event).unwrap()); + let mut cursor = cdp_event_sequence(&first_event).unwrap(); + let cutoffs = HashMap::from([("page-session".to_string(), first_cutoff)]); + let samples = HashMap::from([("page-session".to_string(), Some(100.0))]); + let kind = if method == "Runtime.consoleAPICalled" { + "console" + } else { + "pageerror" + }; + let mut newer_cursor = cdp_event_sequence(&first_event).unwrap(); + let newer_samples = HashMap::from([("page-session".to_string(), Some(200.0))]); + assert!( + process_console_wait_event( + first_event.clone(), + &mut newer_cursor, + 0, + &event_log, + "page-session", + &cutoffs, + &newer_samples, + kind, + None, + Some(&mut state), + None, + None, + false + ) + .await + .unwrap() + .is_none(), + "a newer waiter must retain its own no-replay baseline" + ); + let result = process_console_wait_event( + first_event, + &mut cursor, + 0, + &event_log, + "page-session", + &cutoffs, + &samples, + kind, + None, + Some(&mut state), + None, + None, + false, + ) + .await + .unwrap(); + assert!( + result.is_some(), + "the original waiter's queued event must remain live" + ); + } + } + + #[tokio::test] + async fn page_console_waiter_learns_late_session_replay_windows() { + for method in ["Runtime.consoleAPICalled", "Runtime.exceptionThrown"] { + let harness = navigation_test_harness(8); + let event_log = Arc::clone(&harness.event_log); + let mut state = PageEventStreamState::for_session("page-session".to_string()); + let mut cursor = 0; + let cutoffs = HashMap::new(); + let samples = HashMap::new(); + let kind = if method == "Runtime.consoleAPICalled" { + "console" + } else { + "pageerror" + }; + let (attach, old, live) = { + let mut log = event_log.lock().unwrap(); + let attach = log.push( + json!({"sessionId": "page-session", "method": "Target.attachedToTarget", + "params": {"sessionId": "late-session", "targetInfo": {"type": "iframe"}}}), + ); + let generation = log.begin_console_replay_cutoff("late-session", Some(100.0)); + let make = |timestamp| { + json!({"sessionId": "late-session", "method": method, + "params": {"timestamp": timestamp, "type": "log", "args": [{"type": "string", "value": "message"}], + "exceptionDetails": {"text": "message"}}}) + }; + let old = log.push(make(99.0)); + let live = log.push(make(100.0)); + let end = log.cursor(); + log.finalize_console_replay_cutoff("late-session", generation, end); + (attach, old, live) + }; + for event in [attach, old] { + assert!(process_console_wait_event( + event, + &mut cursor, + 0, + &event_log, + "page-session", + &cutoffs, + &samples, + kind, + Some(Arc::clone(&harness.page)), + Some(&mut state), + None, + None, + false + ) + .await + .unwrap() + .is_none()); + } + assert!(process_console_wait_event( + live, + &mut cursor, + 0, + &event_log, + "page-session", + &cutoffs, + &samples, + kind, + Some(Arc::clone(&harness.page)), + Some(&mut state), + None, + None, + false + ) + .await + .unwrap() + .is_some()); + } + } + #[test] + fn console_replay_invalid_clock_sample_keeps_event_live() { + let mut log = CdpEventLog::new(); + let generation = log.begin_console_replay_cutoff("page-session", None); + let event = log.push(json!({ + "sessionId": "page-session", + "method": "Runtime.consoleAPICalled", + "params": { + "timestamp": 1.0, + "type": "log", + "args": [{"type": "string", "value": "live"}] + } + })); + let cutoff = log.cursor(); + log.finalize_console_replay_cutoff("page-session", generation, cutoff); + let event_log = Arc::new(Mutex::new(log)); + let cutoffs = HashMap::from([("page-session".to_string(), cutoff)]); + assert!(!console_event_is_replay_history_for_sample( + &event, + &event_log, + &cutoffs, + "page-session", + Some(None), + )); + } #[test] fn cdp_event_log_tombstones_payloads_that_remain_oversized_after_compaction() { let mut log = CdpEventLog::new(); @@ -23167,6 +24574,7 @@ return this.dataset.mainWorldOverride === "observed"; let waiter = tokio::spawn(async move { let mut cursor = 0; let replay_until = HashMap::new(); + let replay_clock_samples = HashMap::new(); process_console_wait_event( event, &mut cursor, @@ -23174,6 +24582,7 @@ return this.dataset.mainWorldOverride === "observed"; &waiter_log, "page-session", &replay_until, + &replay_clock_samples, "console", Some(waiter_page), None, @@ -23942,9 +25351,8 @@ return this.dataset.mainWorldOverride === "observed"; retained_bytes: 0, retention_gate: Arc::new(CdpRetentionGate::new()), action_dispatch_receipts: HashMap::new(), - console_replay_cutoffs: HashMap::new(), - console_replay_cutoff_generations: HashMap::new(), - console_replay_cutoff_cancellations: HashMap::new(), + console_replay_windows: HashMap::new(), + active_dialogs: HashMap::new(), next_console_replay_cutoff_generation: 0, }; let initial = json!({ @@ -24194,7 +25602,7 @@ return this.dataset.mainWorldOverride === "observed"; #[cfg(feature = "python")] #[test] fn python_launch_option_parse_errors_remain_value_errors() { - Python::initialize(); + initialize_test_python(); Python::attach(|py| { let error = parse_python_launch_options("{").unwrap_err(); assert!(error.is_instance_of::(py)); @@ -25480,20 +26888,6 @@ fn wrap_cdp_command_error(method: &str, error: RwError) -> RwError { } } -fn merge_console_replay_cutoff(stored: &mut u64, cutoff: u64) { - if *stored == u64::MAX && cutoff != u64::MAX { - // A finite response cursor finalizes an in-flight provisional sentinel. Do not let - // overflow-recreated state retain the sentinel forever. - *stored = cutoff; - } else if cutoff == u64::MAX { - // A new capture generation must suppress replay immediately, even if an older finite - // cutoff is still retained for a slow ordered consumer. - *stored = u64::MAX; - } else { - *stored = (*stored).max(cutoff); - } -} - fn compact_retained_cdp_value(value: &mut Value) { match value { Value::String(text) => { @@ -25750,6 +27144,83 @@ impl CdpRetentionGate { } } +fn console_event_timestamp(event: &Value) -> Option { + let timestamp = event.pointer("/params/timestamp").and_then(Value::as_f64)?; + timestamp.is_finite().then_some(timestamp) +} + +fn console_event_is_replay_history( + event: &Value, + event_log: &Arc>, + replay_until: &HashMap, + replay_clock_samples: &HashMap>, +) -> bool { + let Some(session_id) = event.get("sessionId").and_then(Value::as_str) else { + return false; + }; + console_event_is_replay_history_for_sample( + event, + event_log, + replay_until, + session_id, + replay_clock_samples.get(session_id).copied(), + ) +} + +fn console_event_is_replay_history_for_sample( + event: &Value, + event_log: &Arc>, + replay_until: &HashMap, + session_id: &str, + replay_clock_sample: Option>, +) -> bool { + if !matches!( + event.get("method").and_then(Value::as_str), + Some("Runtime.consoleAPICalled") | Some("Runtime.exceptionThrown") + ) { + return false; + } + let Some(sequence) = cdp_event_sequence(event) else { + return false; + }; + let log = event_log.lock().unwrap(); + let Some(window) = log.console_replay_window(session_id, sequence) else { + return false; + }; + // A waiter retains its own setup baseline. Events from later-attached + // sessions use the window in which they arrived instead. + let sample = if replay_until + .get(session_id) + .is_some_and(|cutoff| sequence < *cutoff) + { + replay_clock_sample.unwrap_or(window.sample) + } else { + window.sample + }; + window.is_history(event, sample) +} + +#[derive(Clone)] +struct ConsoleReplayWindow { + generation: u64, + start: u64, + end: u64, + sample: Option, +} + +impl ConsoleReplayWindow { + fn contains(&self, sequence: u64) -> bool { + self.start <= sequence && sequence < self.end + } + + fn is_history(&self, event: &Value, sample: Option) -> bool { + cdp_event_sequence(event).is_some_and(|sequence| self.contains(sequence)) + && sample.is_some_and(|sample| { + console_event_timestamp(event).is_some_and(|timestamp| timestamp < sample) + }) + } +} + struct CdpEventLogEntry { sequence: u64, event: Value, @@ -25762,9 +27233,9 @@ struct CdpEventLog { retained_bytes: usize, retention_gate: Arc, action_dispatch_receipts: HashMap, - console_replay_cutoffs: HashMap, - console_replay_cutoff_generations: HashMap, - console_replay_cutoff_cancellations: HashMap, + console_replay_windows: HashMap>, + // Live dialogs must survive eviction until Chromium closes them. + active_dialogs: HashMap, next_console_replay_cutoff_generation: u64, } @@ -25776,85 +27247,118 @@ impl CdpEventLog { retained_bytes: 0, retention_gate: Arc::new(CdpRetentionGate::new()), action_dispatch_receipts: HashMap::new(), - console_replay_cutoff_cancellations: HashMap::new(), - console_replay_cutoffs: HashMap::new(), - console_replay_cutoff_generations: HashMap::new(), + console_replay_windows: HashMap::new(), + active_dialogs: HashMap::new(), next_console_replay_cutoff_generation: 0, } } - fn begin_console_replay_cutoff(&mut self, session_id: &str) -> u64 { + fn begin_console_replay_cutoff(&mut self, session_id: &str, sample: Option) -> u64 { advance_lifetime_counter(&mut self.next_console_replay_cutoff_generation); let generation = self.next_console_replay_cutoff_generation; - self.console_replay_cutoff_generations - .insert(session_id.to_string(), generation); - self.console_replay_cutoff_cancellations.remove(session_id); - // The provisional sentinel is deliberately published in the event-log metadata. - // Every ordered consumer reads this map, so no replay event can pass while Runtime.enable - // is still in flight. - self.console_replay_cutoffs - .insert(session_id.to_string(), u64::MAX); + let start = self.cursor(); + let windows = self + .console_replay_windows + .entry(session_id.to_string()) + .or_default(); + if let Some(previous) = windows.back_mut() { + previous.end = previous.end.min(start); + } + windows.push_back(ConsoleReplayWindow { + generation, + start, + end: u64::MAX, + sample, + }); + self.prune_console_replay_cutoffs(); generation } fn finalize_console_replay_cutoff(&mut self, session_id: &str, generation: u64, cutoff: u64) { - if self.console_replay_cutoff_generations.get(session_id) == Some(&generation) { - self.console_replay_cutoffs - .insert(session_id.to_string(), cutoff); - self.console_replay_cutoff_cancellations.remove(session_id); - self.prune_console_replay_cutoffs(); + if let Some(window) = self + .console_replay_windows + .get_mut(session_id) + .and_then(|windows| { + windows + .iter_mut() + .find(|window| window.generation == generation) + }) + { + window.end = window.end.min(cutoff); } + self.prune_console_replay_cutoffs(); } + fn cancel_console_replay_cutoff(&mut self, session_id: &str, generation: u64) { - if self.console_replay_cutoff_generations.get(session_id) == Some(&generation) { - let cutoff = self.cursor(); - self.console_replay_cutoffs - .insert(session_id.to_string(), cutoff); - self.console_replay_cutoff_generations.remove(session_id); - self.console_replay_cutoff_cancellations - .insert(session_id.to_string(), generation); - self.prune_console_replay_cutoffs(); + let cutoff = self.cursor(); + if let Some(window) = self + .console_replay_windows + .get_mut(session_id) + .and_then(|windows| { + windows + .iter_mut() + .find(|window| window.generation == generation) + }) + { + window.end = window.end.min(cutoff); + window.sample = None; } + self.prune_console_replay_cutoffs(); } + fn finalize_console_replay_cutoff_on_detach(&mut self, session_id: &str, cutoff: Option) { - let generation = self.console_replay_cutoff_generations.remove(session_id); - if self.console_replay_cutoffs.get(session_id) == Some(&u64::MAX) { - self.console_replay_cutoffs.insert( - session_id.to_string(), - cutoff.unwrap_or_else(|| self.cursor()), - ); - if let Some(generation) = generation { - self.console_replay_cutoff_cancellations - .insert(session_id.to_string(), generation); - } + let cutoff = cutoff.unwrap_or_else(|| self.cursor()); + if let Some(window) = self + .console_replay_windows + .get_mut(session_id) + .and_then(|windows| windows.back_mut()) + { + window.end = window.end.min(cutoff); } self.prune_console_replay_cutoffs(); } - fn console_replay_cutoff_with_generation(&self, session_id: &str) -> Option<(u64, u64)> { - let generation = self - .console_replay_cutoff_generations - .get(session_id) - .copied() - .or_else(|| { - self.console_replay_cutoff_cancellations - .get(session_id) - .copied() - })?; - Some((generation, self.console_replay_cutoff(session_id)?)) - } + #[cfg(test)] fn console_replay_cutoff(&self, session_id: &str) -> Option { - self.console_replay_cutoffs.get(session_id).copied() + self.console_replay_windows + .get(session_id)? + .back() + .map(|window| window.end) + } + + #[cfg(test)] + fn console_replay_sample(&self, session_id: &str) -> Option> { + self.console_replay_windows + .get(session_id)? + .back() + .map(|window| window.sample) + } + + fn console_replay_window( + &self, + session_id: &str, + sequence: u64, + ) -> Option<&ConsoleReplayWindow> { + self.console_replay_windows + .get(session_id)? + .iter() + .find(|window| window.contains(sequence)) } fn prune_console_replay_cutoffs(&mut self) { let oldest_seq = self.oldest_seq(); - self.console_replay_cutoffs - .retain(|_, stored| *stored == u64::MAX || *stored >= oldest_seq); - self.console_replay_cutoff_cancellations - .retain(|session_id, _| self.console_replay_cutoffs.contains_key(session_id)); - self.console_replay_cutoff_generations - .retain(|session_id, _| self.console_replay_cutoffs.contains_key(session_id)); + self.console_replay_windows.retain(|_, windows| { + // Keep the current window even when empty, but do not accumulate + // superseded empty generations or windows older than the event log. + let current = windows.back().map(|window| window.generation); + windows.retain(|window| { + Some(window.generation) == current + || (window.start < window.end && window.end > oldest_seq) + }); + windows + .back() + .is_some_and(|window| window.end >= oldest_seq) + }); } fn cursor(&self) -> u64 { @@ -25870,6 +27374,31 @@ impl CdpEventLog { Value::Number(seq.into()), ); } + match event.get("method").and_then(Value::as_str) { + Some("Page.javascriptDialogOpening") => { + if let (Some(session), Some(mut dialog)) = ( + event.get("sessionId").and_then(Value::as_str), + dialog_from_event(&event), + ) { + compact_retained_cdp_value(&mut dialog); + self.active_dialogs + .insert(session.to_string(), (seq, dialog)); + } + } + Some( + "Page.javascriptDialogClosed" | "Inspector.detached" | "Inspector.targetCrashed", + ) => { + if let Some(session) = event.get("sessionId").and_then(Value::as_str) { + self.active_dialogs.remove(session); + } + } + Some("Target.detachedFromTarget") => { + if let Some(session) = event.pointer("/params/sessionId").and_then(Value::as_str) { + self.active_dialogs.remove(session); + } + } + _ => {} + } let (retained_event, retained_bytes) = compact_retained_cdp_event(event.clone()); self.retained_bytes = self.retained_bytes.saturating_add(retained_bytes); self.events.push_back(CdpEventLogEntry { @@ -25885,8 +27414,6 @@ impl CdpEventLog { }; self.retained_bytes = self.retained_bytes.saturating_sub(evicted.retained_bytes); } - // Cutoff pruning is low-water driven, not part of the hot event path. A bounded batch - // keeps the map inexpensive under high-volume Runtime/Network traffic. let oldest_seq = self.oldest_seq(); if oldest_seq != 0 && oldest_seq % 256 == 0 { self.prune_console_replay_cutoffs(); @@ -25941,9 +27468,8 @@ impl CdpEventLog { self.events = VecDeque::new(); self.retained_bytes = 0; self.action_dispatch_receipts = HashMap::new(); - self.console_replay_cutoffs = HashMap::new(); - self.console_replay_cutoff_generations = HashMap::new(); - self.console_replay_cutoff_cancellations = HashMap::new(); + self.console_replay_windows = HashMap::new(); + self.active_dialogs = HashMap::new(); } fn entries_since(&self, cursor: u64) -> Vec<(u64, Value)> { @@ -26767,7 +28293,11 @@ impl CdpClient { } fn retention_gate(&self) -> Arc { - self.event_log.lock().unwrap().retention_gate.clone() + self.event_log + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retention_gate + .clone() } fn retention_enabled(&self) -> bool { @@ -27724,10 +29254,10 @@ impl BrowserInner { } fn close_last_owner_without_gil(&self) -> RwResult<()> { - let sender = match self.lifecycle.start() { + let (sender, generation) = match self.lifecycle.start() { CloseStart::Done(outcome) => return outcome.into_result(), CloseStart::Wait(_) => return Ok(()), - CloseStart::Lead(sender) => sender, + CloseStart::Lead { sender, generation } => (sender, generation), }; let owns_browser_process = self.process.lock().unwrap().is_some(); if owns_browser_process && tokio::runtime::Handle::try_current().is_err() { @@ -27766,7 +29296,7 @@ impl BrowserInner { self.profile_dir.lock().unwrap().take(); let result = cleanup_error.map_or(Ok(()), |error| Err(RwError::Io(error))); - self.lifecycle.finish(sender, &result, true); + self.lifecycle.finish(sender, generation, &result, true); result } } @@ -28383,6 +29913,7 @@ struct PageInner { struct ConsoleCaptureState { requested: AtomicBool, enabled_sessions: Arc>>, + setup_lock: Arc>, } struct CreatedTargetGuard { @@ -28570,9 +30101,17 @@ impl PageInner { } fn release_memory_buffers(&self) { + self.lifecycle.clear_before_unload(); self.target_closed.store(true, Ordering::SeqCst); let retention_gate = self.browser.client.retention_gate(); let _retention_guard = retention_gate.lock_for_release(); + self.browser + .client + .event_log + .lock() + .unwrap() + .active_dialogs + .remove(&self.session_id); self.network_requests.lock().unwrap().clear_for_close(); self.native_network_records .lock() @@ -28645,6 +30184,13 @@ impl Drop for PageInner { self as *const PageInner, ); self.close_in_background(); + self.browser + .client + .event_log + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_dialogs + .remove(&self.session_id); } } @@ -31187,19 +32733,56 @@ struct PyPageEventStream { pending_batch: Arc>>, close_tx: watch::Sender, closed: Arc, + active_wait: Arc>>>, } #[derive(Clone)] struct PageEventStreamState { network: NetworkObservationState, ready: BTreeMap, + terminal_cursor: Option, main_session_id: String, owned_sessions: HashSet, session_parents: HashMap, - replay_until: HashMap, - replay_cutoff_generations: HashMap, + replay_windows: HashMap>, +} + +#[cfg(feature = "python")] +struct PageEventStreamWaitState { + abort_handle: Mutex>, + cancelled: AtomicBool, + restored: watch::Sender, +} +#[cfg(feature = "python")] +impl PageEventStreamWaitState { + fn new() -> Self { + Self { + abort_handle: Mutex::new(None), + cancelled: AtomicBool::new(false), + restored: watch::channel(false).0, + } + } + + fn set_abort_handle(&self, abort_handle: tokio::task::AbortHandle) { + let mut slot = self.abort_handle.lock().unwrap(); + if self.cancelled.load(Ordering::SeqCst) { + abort_handle.abort(); + } else { + *slot = Some(abort_handle); + } + } + + fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(abort_handle) = self.abort_handle.lock().unwrap().take() { + abort_handle.abort(); + } + } } +#[cfg(feature = "python")] +type PageEventStreamCancelHook = Arc; + #[cfg(feature = "python")] struct PageEventStreamLease { receiver: Option>, @@ -31214,6 +32797,7 @@ struct PageEventStreamLease { requests: Arc>, working_requests: Arc>, page: Option>, + restored: Option>, } #[cfg(feature = "python")] @@ -31237,7 +32821,6 @@ impl PendingPageEventBatch { } } } - #[cfg(feature = "python")] impl PageEventStreamLease { fn deliver(mut self) { @@ -31255,12 +32838,7 @@ impl PageEventStreamLease { *self.state_slot.lock().unwrap() = self.rollback_state.take(); *self.cursor_slot.lock().unwrap() = self.rollback_cursor; } -} - -#[cfg(feature = "python")] -impl Drop for PageEventStreamLease { - fn drop(&mut self) { - *self.receiver_slot.lock().unwrap() = self.receiver.take(); + fn restore(&mut self) { if self.delivered { if let Some(page) = self.page.as_ref().map(Arc::clone) { let retention_gate = page.browser.client.retention_gate(); @@ -31268,8 +32846,9 @@ impl Drop for PageEventStreamLease { self.restore_rollback(); return; }; - if page.lifecycle.is_closing_or_closed() - || page.target_closed.load(Ordering::SeqCst) + if (page.lifecycle.is_closing_or_closed() + || page.target_closed.load(Ordering::SeqCst)) + && !page.lifecycle.allows_events_during_close() { self.restore_rollback(); return; @@ -31282,6 +32861,36 @@ impl Drop for PageEventStreamLease { } } +#[cfg(feature = "python")] +impl Drop for PageEventStreamLease { + fn drop(&mut self) { + #[cfg(test)] + let handoff = tests::page_event_stream_handoff_barriers() + .lock() + .unwrap() + .remove(&(Arc::as_ptr(&self.state_slot) as usize)); + #[cfg(test)] + if let Some(barrier) = &handoff { + barrier.entered.send(()).unwrap(); + barrier + .release + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + } + self.restore(); + // The receiver is the availability token. Publish it only after state, cursor, + // and request history have all been restored or committed. + *self.receiver_slot.lock().unwrap() = self.receiver.take(); + if let Some(restored) = &self.restored { + restored.send_replace(true); + } + #[cfg(test)] + if let Some(barrier) = handoff { + barrier.restored.send(()).unwrap(); + } + } +} + impl PageEventStreamState { #[cfg(test)] fn new() -> Self { @@ -31296,11 +32905,11 @@ impl PageEventStreamState { Self { network: NetworkObservationState::new(), ready: BTreeMap::new(), + terminal_cursor: None, main_session_id, owned_sessions, session_parents: HashMap::new(), - replay_until: HashMap::new(), - replay_cutoff_generations: HashMap::new(), + replay_windows: HashMap::new(), } } @@ -31329,11 +32938,6 @@ impl PageEventStreamState { } } drop(frame_state); - state.replay_until = page - .console_replay_until_event_cursor - .lock() - .unwrap() - .clone(); state } @@ -31346,9 +32950,7 @@ impl PageEventStreamState { let receiver = client.events.subscribe(); let event_cursor = event_log.cursor(); let entries = event_log.entries_since(0); - let replay_cutoffs = event_log.console_replay_cutoffs.clone(); - let replay_generations = event_log.console_replay_cutoff_generations.clone(); - let replay_cancellations = event_log.console_replay_cutoff_cancellations.clone(); + let replay_windows = event_log.console_replay_windows.clone(); drop(event_log); let retention_gate = client.retention_gate(); let Some(_retention_guard) = retention_gate.lock_for_write() else { @@ -31383,31 +32985,11 @@ impl PageEventStreamState { } page_state.apply_target_transition(&event, &page.session_id); } - page_state.seed_replay_cutoffs(&replay_cutoffs); - for session_id in replay_cutoffs.keys() { - if let Some(generation) = replay_generations - .get(session_id) - .copied() - .or_else(|| replay_cancellations.get(session_id).copied()) - { - page_state - .replay_cutoff_generations - .insert(session_id.clone(), generation); - } - } + page_state.replay_windows = replay_windows; (receiver, event_cursor, page_state) } - fn seed_replay_cutoffs(&mut self, replay_until: &HashMap) { - for (session_id, cutoff) in replay_until { - self.replay_until - .entry(session_id.clone()) - .and_modify(|stored| merge_console_replay_cutoff(stored, *cutoff)) - .or_insert(*cutoff); - } - } - - fn sync_replay_cutoffs_for_event( + fn sync_replay_windows_for_event( &mut self, event: &Value, event_log: &Arc>, @@ -31435,27 +33017,24 @@ impl PageEventStreamState { } let log = event_log.lock().unwrap(); for session_id in session_ids { - let Some(cutoff) = log.console_replay_cutoff(session_id) else { - continue; - }; - if let Some((generation, _)) = log.console_replay_cutoff_with_generation(session_id) { - if self - .replay_cutoff_generations - .get(session_id) - .is_some_and(|stored| *stored > generation) - { - continue; - } - self.replay_cutoff_generations - .insert(session_id.to_string(), generation); + if let Some(windows) = log.console_replay_windows.get(session_id) { + self.replay_windows + .insert(session_id.to_string(), windows.clone()); } - self.replay_until - .entry(session_id.to_string()) - .and_modify(|stored| merge_console_replay_cutoff(stored, cutoff)) - .or_insert(cutoff); } } + fn console_event_is_replay_history(&self, event: &Value) -> bool { + let Some(session_id) = event.get("sessionId").and_then(Value::as_str) else { + return false; + }; + self.replay_windows.get(session_id).is_some_and(|windows| { + windows + .iter() + .any(|window| window.is_history(event, window.sample)) + }) + } + fn ensure_main_session(&mut self, session_id: &str) { if self.main_session_id.is_empty() { self.main_session_id = session_id.to_string(); @@ -31500,7 +33079,7 @@ impl PageEventStreamState { } let mut pending = vec![detached_session_id.to_string()]; while let Some(session_id) = pending.pop() { - self.replay_until.remove(&session_id); + self.replay_windows.remove(&session_id); if !self.owned_sessions.remove(&session_id) { continue; } @@ -31522,11 +33101,27 @@ impl PageEventStreamState { } } + fn undispatched_active_dialogs( + &self, + log: &CdpEventLog, + unread: std::ops::Range, + ) -> BTreeMap { + log.active_dialogs + .iter() + .filter_map(|(session, (seq, dialog))| { + // Never replay a dialog already handed to Python, including + // one still owned by a listener or accepted waiter. + (self.owns_session(session) + && (unread.contains(seq) || self.ready.contains_key(seq))) + .then(|| (*seq, page_event_envelope(*seq, "dialog", dialog.clone()))) + }) + .collect() + } + fn reset_after_overflow(&mut self) { self.network = NetworkObservationState::new(); self.ready.clear(); - self.replay_until.clear(); - self.replay_cutoff_generations.clear(); + self.replay_windows.clear(); } } @@ -31563,6 +33158,7 @@ struct PyConsoleEventWaiter { session_id: String, event_cursor: Mutex, replay_until: HashMap, + replay_clock_samples: HashMap>, event_kind: String, page: Option>, page_state: Mutex>, @@ -38526,7 +40122,9 @@ async fn page_close_cleanup( Arc::as_ptr(&page), ); page.close_target_on_drop.store(false, Ordering::SeqCst); - page.release_memory_buffers(); + if !run_before_unload { + page.release_memory_buffers(); + } Ok(()) } @@ -38536,12 +40134,14 @@ async fn page_close_async( run_before_unload: bool, ) -> RwResult<()> { let lifecycle = Arc::clone(&page.lifecycle); - single_flight_close(lifecycle, false, move || async move { - page.clear_worker_resume_handoffs(); - page.abort_iframe_setup_tasks(); - page_close_cleanup(page, timeout, run_before_unload).await - }) - .await + let result = + single_flight_close_mode(lifecycle, false, run_before_unload, move || async move { + page.clear_worker_resume_handoffs(); + page.abort_iframe_setup_tasks(); + page_close_cleanup(page, timeout, run_before_unload).await + }) + .await; + result } async fn dispatch_mouse_click_sequence_locked_async( @@ -38721,6 +40321,9 @@ impl PyPage { fn event_cursor(&self) -> u64 { self.inner.browser.client.event_cursor() } + fn is_closed(&self) -> bool { + self.inner.target_closed.load(Ordering::SeqCst) + } fn background_override_active(&self) -> bool { self.inner.background_override_active.load(Ordering::SeqCst) @@ -41416,6 +43019,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; pending_batch: Arc::new(Mutex::new(None)), close_tx, closed: Arc::new(AtomicBool::new(false)), + active_wait: Arc::new(Mutex::new(None)), }; Ok(stream) } @@ -41449,46 +43053,61 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); let client = Arc::clone(&browser.client); - let (receiver, event_cursor, mut page_wait_state) = - PageEventStreamState::subscribe_page_event_stream(&page); let timeout = BrowserInner::command_timeout(timeout_ms); + let deadline = OperationDeadline::new(timeout); + let page_for_setup = Arc::clone(&page); + let samples = browser + .block_on(async move { prepare_console_capture(&page_for_setup, deadline).await }) + .map_err(py_err)?; + let (receiver, event_cursor, page_wait_state) = + PageEventStreamState::subscribe_page_event_stream(&page); + let replay_clock_samples = samples.clone(); let page_for_setup = Arc::clone(&page); let replay_until = browser - .block_on(async move { enable_console_capture(&page_for_setup, timeout).await }) + .block_on(async move { + enable_console_capture_with_samples(&page_for_setup, deadline, samples).await + }) .map_err(py_err)?; - page_wait_state.seed_replay_cutoffs(&replay_until); Ok(PyConsoleEventWaiter { browser: Arc::clone(&self.inner.browser), receiver: Mutex::new(Some(receiver)), event_log: Arc::clone(&client.event_log), session_id: self.inner.session_id.clone(), event_cursor: Mutex::new(event_cursor), + replay_clock_samples, replay_until, event_kind: "console".to_string(), page: Some(Arc::clone(&self.inner)), page_state: Mutex::new(Some(page_wait_state)), }) } - #[pyo3(signature = (timeout_ms=None))] fn page_error_event_waiter(&self, timeout_ms: Option) -> PyResult { let page = Arc::clone(&self.inner); let browser = Arc::clone(&page.browser); let client = Arc::clone(&browser.client); - let (receiver, event_cursor, mut page_wait_state) = - PageEventStreamState::subscribe_page_event_stream(&page); let timeout = BrowserInner::command_timeout(timeout_ms); + let deadline = OperationDeadline::new(timeout); + let page_for_setup = Arc::clone(&page); + let samples = browser + .block_on(async move { prepare_console_capture(&page_for_setup, deadline).await }) + .map_err(py_err)?; + let (receiver, event_cursor, page_wait_state) = + PageEventStreamState::subscribe_page_event_stream(&page); + let replay_clock_samples = samples.clone(); let page_for_setup = Arc::clone(&page); let replay_until = browser - .block_on(async move { enable_console_capture(&page_for_setup, timeout).await }) + .block_on(async move { + enable_console_capture_with_samples(&page_for_setup, deadline, samples).await + }) .map_err(py_err)?; - page_wait_state.seed_replay_cutoffs(&replay_until); Ok(PyConsoleEventWaiter { browser: Arc::clone(&self.inner.browser), receiver: Mutex::new(Some(receiver)), event_log: Arc::clone(&client.event_log), session_id: self.inner.session_id.clone(), event_cursor: Mutex::new(event_cursor), + replay_clock_samples, replay_until, event_kind: "pageerror".to_string(), page: Some(Arc::clone(&self.inner)), @@ -42171,6 +43790,10 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .map_err(py_err) } + fn release_memory_buffers(&self) { + self.inner.release_memory_buffers(); + } + fn mark_delivered(&self) { self.inner.mark_delivered(); } @@ -42522,15 +44145,26 @@ impl PyWorker { let client = Arc::clone(&browser.client); let session_id = self.session_id.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); + let deadline = OperationDeadline::new(timeout); + // Workers have no isolated world. An application clock cannot establish + // a trusted replay boundary, so preserve events when the sample is uncertain. + let sample = None; let (receiver, event_cursor) = client.subscribe_with_cursor(); let setup_client = Arc::clone(&client); let setup_session_id = session_id.clone(); + let enable_timeout = deadline.remaining().map_err(py_err)?; let replay_until = browser .block_on(async move { - enable_runtime_with_local_console_cutoff(&setup_client, &setup_session_id, timeout) - .await + enable_runtime_with_local_console_cutoff_at_sample( + &setup_client, + &setup_session_id, + enable_timeout, + sample, + ) + .await }) .map_err(py_err)?; + let replay_clock_samples = std::iter::once((session_id.clone(), sample)).collect(); let replay_until = std::iter::once((session_id.clone(), replay_until)).collect(); Ok(PyConsoleEventWaiter { browser: Arc::clone(&self.browser), @@ -42538,6 +44172,7 @@ impl PyWorker { event_log: Arc::clone(&client.event_log), session_id: self.session_id.clone(), event_cursor: Mutex::new(event_cursor), + replay_clock_samples, replay_until, event_kind: "console".to_string(), page: None, @@ -42670,6 +44305,22 @@ impl PyNetworkEventWaiter { #[cfg(feature = "python")] impl PyPageEventStream { + fn take_receiver_and_state( + &self, + ) -> PyResult<(broadcast::Receiver, PageEventStreamState)> { + let mut receiver_slot = self.receiver.lock().unwrap(); + let receiver = receiver_slot + .take() + .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; + let Some(state) = self.state.lock().unwrap().take() else { + *receiver_slot = Some(receiver); + return Err(PyRuntimeError::new_err( + "page event stream is already waiting", + )); + }; + Ok((receiver, state)) + } + fn take_pending_batch(&self) -> Option { self.pending_batch.lock().unwrap().take() } @@ -42677,6 +44328,14 @@ impl PyPageEventStream { fn rollback_pending_batch(&self) { drop(self.take_pending_batch()); } + fn cancel_active_wait(&self) -> Option> { + self.rollback_pending_batch(); + let wait_state = self.active_wait.lock().unwrap().clone(); + if let Some(wait_state) = &wait_state { + wait_state.cancel(); + } + wait_state + } } #[cfg(feature = "python")] @@ -42687,11 +44346,41 @@ impl PyPageEventStream { batch.acknowledge(); } } + fn cancel_wait(&self) { + self.cancel_active_wait(); + } + + fn cancel_wait_async(&self, py: Python<'_>) -> PyResult> { + let wait_state = self.cancel_active_wait(); + python_future_on( + py, + self.browser.runtime.handle().clone(), + async move { + if let Some(wait_state) = wait_state { + let mut restored = wait_state.restored.subscribe(); + while !*restored.borrow_and_update() { + if restored.changed().await.is_err() { + break; + } + } + } + Ok(()) + }, + |py, ()| Ok(py.None()), + ) + } fn rollback_batch(&self) { self.rollback_pending_batch(); } + fn event_cursor(&self) -> u64 { + *self.cursor.lock().unwrap() + } + fn is_closed(&self) -> bool { + self.closed.load(Ordering::SeqCst) + } + fn enable_runtime(&self) -> PyResult<()> { let page = Arc::clone(&self.page); let browser = Arc::clone(&self.browser); @@ -42739,18 +44428,7 @@ impl PyPageEventStream { Value::Array(vec![page_event_envelope(cursor, "_closed", Value::Null)]).to_string(), ); } - let mut receiver = self - .receiver - .lock() - .unwrap() - .take() - .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; - let mut state = self - .state - .lock() - .unwrap() - .take() - .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; + let (mut receiver, mut state) = self.take_receiver_and_state()?; let browser = Arc::clone(&self.browser); let event_log = Arc::clone(&self.event_log); let mut cursor = *self.cursor.lock().unwrap(); @@ -42776,12 +44454,12 @@ impl PyPageEventStream { )); (batch, cursor, state, terminal, receiver) }); - *self.receiver.lock().unwrap() = Some(receiver); *self.cursor.lock().unwrap() = cursor; *self.state.lock().unwrap() = Some(state); if terminal { self.closed.store(true, Ordering::SeqCst); } + *self.receiver.lock().unwrap() = Some(receiver); Ok(Value::Array(batch).to_string()) } @@ -42808,18 +44486,7 @@ impl PyPageEventStream { future.call_method1("set_result", (payload,))?; return Ok(future.unbind()); } - let receiver = self - .receiver - .lock() - .unwrap() - .take() - .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; - let state = self - .state - .lock() - .unwrap() - .take() - .ok_or_else(|| PyRuntimeError::new_err("page event stream is already waiting"))?; + let (receiver, state) = self.take_receiver_and_state()?; let browser = Arc::clone(&self.browser); let page = Arc::clone(&self.page); let runtime = browser.runtime.handle().clone(); @@ -42830,6 +44497,10 @@ impl PyPageEventStream { let alive_rx = browser.client.alive_tx.subscribe(); let timeout = BrowserInner::command_timeout(timeout_ms); let working_requests = Arc::new(Mutex::new(self.requests.lock().unwrap().clone())); + let wait_state = Arc::new(PageEventStreamWaitState::new()); + *self.active_wait.lock().unwrap() = Some(Arc::clone(&wait_state)); + let cancel_wait_state = Arc::clone(&wait_state); + let cancel_hook: PageEventStreamCancelHook = Arc::new(move || cancel_wait_state.cancel()); let mut lease = PageEventStreamLease { receiver: Some(receiver), rollback_state: Some(state.clone()), @@ -42843,10 +44514,11 @@ impl PyPageEventStream { requests: Arc::clone(&self.requests), working_requests: Arc::clone(&working_requests), page: Some(Arc::clone(&self.page)), + restored: Some(wait_state.restored.clone()), }; let closed = Arc::clone(&self.closed); let pending_batch = Arc::clone(&self.pending_batch); - python_future_on_with_delivery( + let (future, abort_handle) = python_future_on_with_delivery( py, runtime, async move { @@ -42879,7 +44551,10 @@ impl PyPageEventStream { })), }) }, - ) + Some(cancel_hook), + )?; + wait_state.set_abort_handle(abort_handle); + Ok(future) } fn close(&self) { @@ -42983,6 +44658,7 @@ impl PyConsoleEventWaiter { let session_id = self.session_id.clone(); let event_cursor = *self.event_cursor.lock().unwrap(); let replay_until = self.replay_until.clone(); + let replay_clock_samples = self.replay_clock_samples.clone(); let event_kind = self.event_kind.clone(); let page = self.page.clone(); let timeout = BrowserInner::command_timeout(timeout_ms); @@ -42994,6 +44670,7 @@ impl PyConsoleEventWaiter { &session_id, event_cursor, &replay_until, + &replay_clock_samples, &event_kind, page, &mut page_state, @@ -46174,7 +47851,15 @@ return waitForScrollSettle(); let page = Arc::clone(&self.inner); let timeout = BrowserInner::command_timeout(timeout_ms); let browser = Arc::clone(&page.browser); - browser.block_on_raw(page_close_async(page, timeout, run_before_unload)) + let result = browser.block_on_raw(page_close_async( + Arc::clone(&page), + timeout, + run_before_unload, + )); + if result.is_ok() && run_before_unload { + page.release_memory_buffers(); + } + result } fn evaluate_expression(&self, expression: &str, timeout_ms: Option) -> RwResult { @@ -48730,11 +50415,10 @@ async fn register_page_owned_workers_from_event_log(page: &PageInner) -> bool { false } -async fn enable_console_capture( +async fn prepare_console_capture( page: &PageInner, - timeout: Duration, -) -> RwResult> { - let deadline = Instant::now() + timeout; + deadline: OperationDeadline, +) -> RwResult>> { if with_live_page_retention(page, || { page.console_capture.requested.store(true, Ordering::SeqCst); }) @@ -48742,26 +50426,78 @@ async fn enable_console_capture( { return Err(RwError::TargetClosed(TargetClosedKind::Page)); } - if register_page_owned_iframes_from_event_log(page).await - || register_page_owned_workers_from_event_log(page).await - { + // Registration can wait for enabled_sessions while clearing detached sessions. + let unreplayable = tokio::time::timeout(deadline.remaining()?, async { + register_page_owned_iframes_from_event_log(page).await + || register_page_owned_workers_from_event_log(page).await + }) + .await + .map_err(|_| RwError::Timeout(deadline.timeout.as_millis() as u64))?; + if unreplayable { return Err(RwError::Message( "CDP event log contains unreplayable console history".to_string(), )); } - let sessions = { + let (mut sessions, worker_sessions) = { let frame_state = page.frame_state.lock().unwrap(); let mut sessions = frame_state.all_owned_session_ids(); - sessions.extend(frame_state.worker_sessions.iter().cloned()); - sessions.into_iter().collect::>() + let worker_sessions = frame_state.worker_sessions.clone(); + sessions.extend(worker_sessions.iter().cloned()); + ( + sessions.into_iter().collect::>(), + worker_sessions, + ) }; - let mut replay_until = HashMap::with_capacity(sessions.len()); + let enabled_sessions = tokio::time::timeout( + deadline.remaining()?, + page.console_capture.enabled_sessions.lock(), + ) + .await + .map_err(|_| RwError::Timeout(deadline.timeout.as_millis() as u64))?; + deadline.remaining()?; + sessions.retain(|session_id| !enabled_sessions.contains(session_id)); + drop(enabled_sessions); + let mut samples = HashMap::with_capacity(sessions.len()); for session_id in sessions { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(RwError::Timeout(timeout.as_millis() as u64)); - } - let cutoff = enable_console_capture_for_session(page, &session_id, remaining).await?; + let sample = if worker_sessions.contains(&session_id) { + None + } else { + sample_console_replay_clock_for_page(page, &session_id, deadline).await + }; + samples.insert(session_id, sample); + } + Ok(samples) +} + +async fn enable_console_capture( + page: &PageInner, + timeout: Duration, +) -> RwResult> { + let deadline = OperationDeadline::new(timeout); + let _setup_guard = tokio::time::timeout( + deadline.remaining()?, + page.console_capture.setup_lock.lock(), + ) + .await + .map_err(|_| RwError::Timeout(timeout.as_millis() as u64))?; + let samples = prepare_console_capture(page, deadline).await?; + enable_console_capture_with_samples(page, deadline, samples).await +} + +async fn enable_console_capture_with_samples( + page: &PageInner, + deadline: OperationDeadline, + samples: HashMap>, +) -> RwResult> { + let mut replay_until = HashMap::with_capacity(samples.len()); + for (session_id, sample) in samples { + let cutoff = enable_console_capture_for_session_at_sample( + page, + &session_id, + deadline.remaining()?, + sample, + ) + .await?; replay_until.insert(session_id, cutoff); } Ok(replay_until) @@ -48779,52 +50515,155 @@ async fn enable_console_capture_for_session_if_requested( .await .map(Some) } +async fn sample_console_replay_clock_for_page( + page: &PageInner, + session_id: &str, + deadline: OperationDeadline, +) -> Option { + let frame_id = if session_id == page.session_id { + page.main_frame_id(&page.browser.client, session_id, deadline.remaining().ok()?) + .await + .ok() + } else { + page.frame_state + .lock() + .unwrap() + .session_frames + .get(session_id) + .cloned() + }; + // Never fall back to the application's default-world Date implementation. + let frame_id = frame_id?; + let context_id = create_isolated_world_for_frame( + &page.browser.client, + session_id, + &frame_id, + deadline.remaining().ok()?, + ) + .await + .ok()?; + let response = page + .browser + .client + .send( + "Runtime.evaluate", + json!({ + "expression": "Date.now()", + "contextId": context_id, + "returnByValue": true, + "awaitPromise": false, + }), + Some(session_id), + deadline.remaining().ok()?, + ) + .await + .ok()?; + if response.get("exceptionDetails").is_some() + || response.pointer("/result/exceptionDetails").is_some() + { + return None; + } + response + .pointer("/result/result/value") + .or_else(|| response.pointer("/result/value")) + .and_then(Value::as_f64) + .filter(|sample| sample.is_finite()) +} + +fn begin_console_replay_cutoff(client: &CdpClient, session_id: &str, sample: Option) -> u64 { + client + .event_log + .lock() + .unwrap() + .begin_console_replay_cutoff(session_id, sample) +} + +fn cancel_console_replay_cutoff(client: &CdpClient, session_id: &str, generation: u64) { + client + .event_log + .lock() + .unwrap() + .cancel_console_replay_cutoff(session_id, generation); +} -async fn enable_runtime_with_local_console_cutoff( +async fn enable_runtime_with_local_console_cutoff_at_sample( client: &CdpClient, session_id: &str, timeout: Duration, + sample: Option, ) -> RwResult { - client + let generation = begin_console_replay_cutoff(client, session_id, sample); + match client .send("Runtime.enable", json!({}), Some(session_id), timeout) - .await?; - Ok(client.event_cursor()) + .await + { + Ok(_) => { + let cutoff = client.event_cursor(); + client + .event_log + .lock() + .unwrap() + .finalize_console_replay_cutoff(session_id, generation, cutoff); + Ok(cutoff) + } + Err(error) => { + cancel_console_replay_cutoff(client, session_id, generation); + Err(error) + } + } } - async fn enable_console_capture_for_session( page: &PageInner, session_id: &str, timeout: Duration, ) -> RwResult { - // Hold the enabled-session set across Runtime.enable. This makes first use - // single-flight and records the session before any later console_records() - // call can send the detectable command a second time. - let mut enabled_sessions = page.console_capture.enabled_sessions.lock().await; - let retention_gate = page.browser.client.retention_gate(); - let Some(retention_guard) = retention_gate.lock_for_write() else { - return Err(RwError::TargetClosed(TargetClosedKind::Page)); - }; - if page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst) { - return Err(RwError::TargetClosed(TargetClosedKind::Page)); - } - if enabled_sessions.contains(session_id) { - return Ok(page.browser.client.event_cursor()); + let deadline = OperationDeadline::new(timeout); + let sample = sample_console_replay_clock_for_page(page, session_id, deadline).await; + enable_console_capture_for_session_at_sample(page, session_id, deadline.remaining()?, sample) + .await +} + +async fn enable_console_capture_for_session_at_sample( + page: &PageInner, + session_id: &str, + timeout: Duration, + sample: Option, +) -> RwResult { + // Hold the enabled-session set across Runtime.enable. This makes first use single-flight and + // records the session before any later console_records() call can send it a second time. + let deadline = OperationDeadline::new(timeout); + let mut enabled_sessions = tokio::time::timeout( + deadline.remaining()?, + page.console_capture.enabled_sessions.lock(), + ) + .await + .map_err(|_| RwError::Timeout(timeout.as_millis() as u64))?; + let timeout = deadline.remaining()?; + { + let retention_gate = page.browser.client.retention_gate(); + let Some(_retention_guard) = retention_gate.lock_for_write() else { + return Err(RwError::TargetClosed(TargetClosedKind::Page)); + }; + if page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst) { + return Err(RwError::TargetClosed(TargetClosedKind::Page)); + } + if enabled_sessions.contains(session_id) { + return Ok(page.browser.client.event_cursor()); + } } let started = Instant::now(); - // Publish the provisional cutoff in CdpEventLog before Runtime.enable. Ordered consumers - // all read this sequenced metadata source, so replay cannot pass during setup. - let cutoff_generation = page - .browser - .client - .event_log - .lock() - .unwrap() - .begin_console_replay_cutoff(session_id); - page.console_replay_until_event_cursor - .lock() - .unwrap() - .insert(session_id.to_owned(), u64::MAX); - drop(retention_guard); + let cutoff_generation = begin_console_replay_cutoff(&page.browser.client, session_id, sample); + { + let retention_gate = page.browser.client.retention_gate(); + let Some(_retention_guard) = retention_gate.lock_for_write() else { + cancel_console_replay_cutoff(&page.browser.client, session_id, cutoff_generation); + return Err(RwError::TargetClosed(TargetClosedKind::Page)); + }; + page.console_replay_until_event_cursor + .lock() + .unwrap() + .insert(session_id.to_owned(), u64::MAX); + } let replay_until = match page .browser .client @@ -48852,12 +50691,7 @@ async fn enable_console_capture_for_session( .lock() .unwrap() .remove(session_id); - page.browser - .client - .event_log - .lock() - .unwrap() - .cancel_console_replay_cutoff(session_id, cutoff_generation); + cancel_console_replay_cutoff(&page.browser.client, session_id, cutoff_generation); }); return Err(error); } @@ -48899,7 +50733,6 @@ async fn enable_console_capture_for_session( } Ok(replay_until) } - const ATTACHED_IFRAME_SETUP_RETRY_LIMIT: usize = 1; #[cfg(test)] const ATTACHED_IFRAME_SETUP_TIMEOUT: Duration = Duration::from_secs(5); @@ -49477,7 +51310,8 @@ fn spawn_page_sequence_gated_event_listener( let Some(page) = page.upgrade() else { break; }; - if page.lifecycle.is_closing_or_closed() { + if page.lifecycle.is_closing_or_closed() && !page.lifecycle.allows_events_during_close() + { break; } let event_sequence = cdp_event_sequence(&event); @@ -50070,7 +51904,39 @@ fn reset_page_history_after_unreplayable_locked(page: &PageInner, sequence: u64) .unwrap() .mark_all_frame_caches_dirty(); } +fn is_page_target_close_event(page: &PageInner, event: &Value) -> bool { + match event.get("method").and_then(Value::as_str) { + Some("Target.targetDestroyed") => { + event.pointer("/params/targetId").and_then(Value::as_str) + == Some(page.target_id.as_str()) + } + Some("Inspector.detached") => { + event.get("sessionId").and_then(Value::as_str) == Some(page.session_id.as_str()) + } + Some("Target.detachedFromTarget") => { + event.pointer("/params/sessionId").and_then(Value::as_str) + == Some(page.session_id.as_str()) + } + _ => false, + } +} + async fn handle_page_oopif_event(page: Arc, event: Value) { + // A Page.close response is not terminal receipt. The listener must still + // record target disposal while the beforeunload close lifecycle is active. + if is_page_target_close_event(&page, &event) { + page.target_closed.store(true, Ordering::SeqCst); + page.browser + .client + .event_log + .lock() + .unwrap() + .active_dialogs + .remove(&page.session_id); + page.clear_worker_resume_handoffs(); + page.abort_iframe_setup_tasks(); + return; + } if page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst) || !page.browser.client.retention_enabled() @@ -50114,26 +51980,6 @@ async fn handle_page_oopif_event(page: Arc, event: Value) { page.crashed.store(true, Ordering::SeqCst); return; } - if method == "Target.targetDestroyed" - && event.pointer("/params/targetId").and_then(Value::as_str) - == Some(page.target_id.as_str()) - { - page.target_closed.store(true, Ordering::SeqCst); - page.clear_worker_resume_handoffs(); - page.abort_iframe_setup_tasks(); - return; - } - if (method == "Inspector.detached" - && event.get("sessionId").and_then(Value::as_str) == Some(page.session_id.as_str())) - || (method == "Target.detachedFromTarget" - && event.pointer("/params/sessionId").and_then(Value::as_str) - == Some(page.session_id.as_str())) - { - page.target_closed.store(true, Ordering::SeqCst); - page.clear_worker_resume_handoffs(); - page.abort_iframe_setup_tasks(); - return; - } if method == "Target.attachedToTarget" { let Some(parent_session_id) = event.get("sessionId").and_then(Value::as_str) else { return; @@ -50432,20 +52278,19 @@ fn record_page_observation_event_with_ownership( return; } let event_sequence = cdp_event_sequence(event); - let event_cursor = event_sequence.map(|sequence| sequence.saturating_add(1)); let event_session_id = event.get("sessionId").and_then(Value::as_str); - let capture_baseline_replay = event_session_id - .and_then(|session_id| { - page.console_replay_until_event_cursor - .lock() - .unwrap() - .get(session_id) - .copied() - }) - .is_some_and(|replay_until| { - replay_until == u64::MAX - || event_cursor.is_some_and(|event_cursor| event_cursor <= replay_until) - }); + let capture_baseline_replay = + event_sequence + .zip(event_session_id) + .is_some_and(|(sequence, session)| { + page.browser + .client + .event_log + .lock() + .unwrap() + .console_replay_window(session, sequence) + .is_some_and(|window| window.is_history(event, window.sample)) + }); let method = event.get("method").and_then(Value::as_str).unwrap_or(""); let is_main_session = event_session_id == Some(page.session_id.as_str()); let owned_session = replay_owned_session.unwrap_or_else(|| { @@ -50903,33 +52748,59 @@ mod native_console_record_tests { assert!(write_rx.try_recv().is_err()); let respond = async { - let outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) - .await - .expect("console capture must send Runtime.enable") - .expect("CDP writer must remain open"); - let command = match outgoing { - CdpOutgoing::Text { payload, .. } => { - serde_json::from_str::(&payload).unwrap() + let mut methods = Vec::new(); + loop { + let outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("console capture must send setup command") + .expect("CDP writer must remain open"); + let command = match outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + let method = command["method"].as_str().unwrap(); + methods.push(method.to_string()); + let result = match method { + "Page.getFrameTree" => { + json!({"frameTree": {"frame": {"id": "main-frame"}}}) + } + "Page.createIsolatedWorld" => json!({"executionContextId": 1}), + "Runtime.evaluate" => { + json!({"result": {"type": "number", "value": 100.0}}) + } + "Runtime.enable" => json!({}), + other => panic!("unexpected console setup command: {other}"), + }; + dispatch_cdp_payload( + json!({ "id": command["id"], "result": result }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + if method == "Runtime.enable" { + break; } - CdpOutgoing::Close => panic!("unexpected transport close"), - }; - dispatch_cdp_payload( - json!({ "id": command["id"], "result": {} }), - Arc::clone(&pending), - events.clone(), - Arc::clone(&event_log), - ); - command + } + methods }; - let (first, second, command) = tokio::join!( + let (first, second, methods) = tokio::join!( enable_console_capture(&page, Duration::from_secs(1)), enable_console_capture(&page, Duration::from_secs(1)), respond, ); first.unwrap(); second.unwrap(); - assert_eq!(command["method"], "Runtime.enable"); - assert_eq!(command["sessionId"], "root-session"); + assert!( + methods + == [ + "Page.createIsolatedWorld", + "Runtime.evaluate", + "Runtime.enable" + ] + || methods == ["Runtime.evaluate", "Runtime.enable"] + ); assert!( write_rx.try_recv().is_err(), "concurrent first-use must not send Runtime.enable twice" @@ -50939,8 +52810,48 @@ mod native_console_record_tests { .lock() .unwrap() .record_session_for_frame("late-iframe-frame", "late-iframe-session"); - let respond_late_iframe = async { + let create_outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("late iframe capture must create utility world") + .expect("CDP writer must remain open"); + let create_command = match create_outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + assert_eq!(create_command["method"], "Page.createIsolatedWorld"); + dispatch_cdp_payload( + json!({ + "id": create_command["id"], + "result": {"executionContextId": 2} + }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + let sample_outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("late iframe capture must sample Runtime clock") + .expect("CDP writer must remain open"); + let sample_command = match sample_outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + assert_eq!(sample_command["method"], "Runtime.evaluate"); + assert_eq!(sample_command["params"]["expression"], "Date.now()"); + dispatch_cdp_payload( + json!({ + "id": sample_command["id"], + "result": {"result": {"type": "number", "value": 100.0}} + }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); let outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) .await .expect("late iframe capture must send Runtime.enable") @@ -50951,11 +52862,16 @@ mod native_console_record_tests { } CdpOutgoing::Close => panic!("unexpected transport close"), }; + assert_eq!(command["method"], "Runtime.enable"); for event in [ json!({ "method": "Runtime.consoleAPICalled", "sessionId": "late-iframe-session", - "params": {"type": "log", "args": [{"type": "string", "value": "iframe replay before navigation"}]} + "params": { + "timestamp": 99.0, + "type": "log", + "args": [{"type": "string", "value": "iframe replay before navigation"}] + } }), json!({ "method": "Page.frameNavigated", @@ -50971,12 +52887,29 @@ mod native_console_record_tests { json!({ "method": "Runtime.consoleAPICalled", "sessionId": "late-iframe-session", - "params": {"type": "log", "args": [{"type": "string", "value": "iframe replay after navigation"}]} + "params": { + "timestamp": 99.0, + "type": "log", + "args": [{"type": "string", "value": "iframe replay after navigation"}] + } + }), + json!({ + "method": "Runtime.consoleAPICalled", + "sessionId": "late-iframe-session", + "params": { + "timestamp": 100.0, + "type": "log", + "args": [{"type": "string", "value": "iframe live at sample boundary"}] + } }), json!({ "method": "Runtime.consoleAPICalled", "sessionId": "root-session", - "params": {"type": "log", "args": [{"type": "string", "value": "live main during iframe replay"}]} + "params": { + "timestamp": 100.0, + "type": "log", + "args": [{"type": "string", "value": "live main during iframe replay"}] + } }), ] { dispatch_cdp_payload( @@ -51014,11 +52947,11 @@ mod native_console_record_tests { "params": {"type": "log", "args": [{"type": "string", "value": "iframe after replay window"}]} }), Arc::clone(&pending), - events, - event_log, + events.clone(), + Arc::clone(&event_log), ); tokio::time::timeout(Duration::from_secs(1), async { - while page.observation_event_cursor.load(Ordering::SeqCst) < 6 { + while page.observation_event_cursor.load(Ordering::SeqCst) < 7 { tokio::task::yield_now().await; } }) @@ -51034,6 +52967,7 @@ mod native_console_record_tests { vec![ ("iframe replay before navigation", 0), ("iframe replay after navigation", 0), + ("iframe live at sample boundary", 2), ("live main during iframe replay", 2), ("iframe after replay window", 2), ] @@ -51046,10 +52980,143 @@ mod native_console_record_tests { .map(|record| record.text.as_str()) .collect::>(), vec![ + "iframe live at sample boundary", "live main during iframe replay", - "iframe after replay window" + "iframe after replay window", ] ); + clear_console_capture_for_session(&page, "late-iframe-session").await; + let cursor_before_second_generation = page.observation_event_cursor.load(Ordering::SeqCst); + let respond_second_generation = async { + let create_outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("second-generation capture must create utility world") + .expect("CDP writer must remain open"); + let create_command = match create_outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + assert_eq!(create_command["method"], "Page.createIsolatedWorld"); + dispatch_cdp_payload( + json!({ + "id": create_command["id"], + "result": {"executionContextId": 3} + }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + let sample_outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("second-generation capture must sample Runtime clock") + .expect("CDP writer must remain open"); + let sample_command = match sample_outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + assert_eq!(sample_command["method"], "Runtime.evaluate"); + assert_eq!(sample_command["params"]["expression"], "Date.now()"); + dispatch_cdp_payload( + json!({ + "id": sample_command["id"], + "result": {"result": {"type": "number", "value": 200.0}} + }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + let outgoing = tokio::time::timeout(Duration::from_secs(1), write_rx.recv()) + .await + .expect("second-generation capture must send Runtime.enable") + .expect("CDP writer must remain open"); + let command = match outgoing { + CdpOutgoing::Text { payload, .. } => { + serde_json::from_str::(&payload).unwrap() + } + CdpOutgoing::Close => panic!("unexpected transport close"), + }; + assert_eq!(command["method"], "Runtime.enable"); + for event in [ + json!({ + "method": "Runtime.consoleAPICalled", + "sessionId": "late-iframe-session", + "params": { + "timestamp": 199.0, + "type": "log", + "args": [{"type": "string", "value": "second generation replay"}] + } + }), + json!({ + "method": "Runtime.consoleAPICalled", + "sessionId": "late-iframe-session", + "params": { + "timestamp": 200.0, + "type": "log", + "args": [{"type": "string", "value": "second generation live at boundary"}] + } + }), + ] { + dispatch_cdp_payload( + event, + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + } + dispatch_cdp_payload( + json!({ "id": command["id"], "result": {} }), + Arc::clone(&pending), + events.clone(), + Arc::clone(&event_log), + ); + command + }; + let (second_generation, command) = tokio::join!( + enable_console_capture_for_session_if_requested( + &page, + "late-iframe-session", + Duration::from_secs(1), + ), + respond_second_generation, + ); + second_generation.unwrap(); + assert_eq!(command["method"], "Runtime.enable"); + assert_eq!(command["sessionId"], "late-iframe-session"); + tokio::time::timeout(Duration::from_secs(1), async { + while page.observation_event_cursor.load(Ordering::SeqCst) + < cursor_before_second_generation + 2 + { + tokio::task::yield_now().await; + } + }) + .await + .expect("listener must consume second-generation console records"); + let second_generation_current = page.console_records.lock().unwrap().read(false, false); + let second_generation_texts = second_generation_current + .records + .iter() + .map(|record| record.text.as_str()) + .collect::>(); + assert_eq!( + event_log + .lock() + .unwrap() + .console_replay_sample("late-iframe-session"), + Some(Some(200.0)) + ); + let log = event_log.lock().unwrap(); + let window = log + .console_replay_window("late-iframe-session", cursor_before_second_generation) + .unwrap(); + assert_eq!(window.start, cursor_before_second_generation); + assert_eq!(window.end, cursor_before_second_generation + 2); + drop(log); + assert!(!second_generation_texts.contains(&"second generation replay")); + assert!(second_generation_texts.contains(&"second generation live at boundary")); } } @@ -54177,13 +56244,14 @@ fn process_page_observation_event_with_page( page: Option<&PageInner>, ) { if page.is_some_and(|page| { - page.lifecycle.is_closing_or_closed() - || page.target_closed.load(Ordering::SeqCst) + ((page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst)) + && !page.lifecycle.allows_events_during_close()) || !page.browser.client.retention_enabled() }) { return; } if is_unreplayable_cdp_event(event) { + let recovered_dialogs = state.undispatched_active_dialogs(&event_log.lock().unwrap(), 0..0); if let Some(page) = page { reset_page_history_after_unreplayable_locked(page, seq); *state = PageEventStreamState::for_page(page); @@ -54194,6 +56262,7 @@ fn process_page_observation_event_with_page( .reset_after_overflow(seq.saturating_add(1)); state.reset_after_overflow(); } + state.ready = recovered_dialogs; state.ready.insert( seq, page_event_envelope( @@ -54208,7 +56277,7 @@ fn process_page_observation_event_with_page( return; } state.ensure_main_session(session_id); - state.sync_replay_cutoffs_for_event(event, event_log); + state.sync_replay_windows_for_event(event, event_log); state.apply_target_transition(event, session_id); let method = event.get("method").and_then(Value::as_str).unwrap_or(""); let matched = match process_network_observation_event( @@ -54222,7 +56291,10 @@ fn process_page_observation_event_with_page( ) { Ok(matched) => matched, Err(dropped) => { + let recovered_dialogs = + state.undispatched_active_dialogs(&event_log.lock().unwrap(), 0..0); state.reset_after_overflow(); + state.ready = recovered_dialogs; state.ready.insert( seq, page_event_envelope(seq, "_overflow", json!({ "dropped": dropped })), @@ -54245,14 +56317,7 @@ fn process_page_observation_event_with_page( if !state.owns_session(event_session_id) { return; } - if matches!( - method, - "Runtime.consoleAPICalled" | "Runtime.exceptionThrown" - ) && state - .replay_until - .get(event_session_id) - .is_some_and(|cutoff| *cutoff == u64::MAX || seq < *cutoff) - { + if state.console_event_is_replay_history(event) { return; } let matched = match method { @@ -54341,8 +56406,8 @@ async fn wait_for_page_event_batch( let deadline = tokio::time::Instant::now() + timeout; let mut batch = Vec::with_capacity(max_events); loop { - if page.lifecycle.is_closing_or_closed() - || page.target_closed.load(Ordering::SeqCst) + if ((page.lifecycle.is_closing_or_closed() || page.target_closed.load(Ordering::SeqCst)) + && !page.lifecycle.allows_events_during_close()) || !page.browser.client.retention_enabled() { batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); @@ -54356,47 +56421,102 @@ async fn wait_for_page_event_batch( batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); return (batch, true); } + let terminal_drained = state.terminal_cursor.is_some_and(|end| *cursor >= end); + if terminal_drained { + for (_, pending) in state.network.pending_responses.drain() { + state.ready.insert( + pending.seq, + page_event_envelope(pending.seq, "response", pending.response), + ); + } + } expire_page_responses(state); append_ready_page_events(&mut batch, state, max_events); if batch.len() >= max_events { return (batch, false); } + if terminal_drained { + batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); + return (batch, true); + } let raw_limit = (max_events - batch.len()) .min(PAGE_EVENT_LOG_SCAN_CHUNK) .max(1); - let (oldest_seq, entries) = { + let (oldest_seq, entries, recovered_dialogs) = { let log = event_log.lock().unwrap(); + // A terminal flag certifies only a snapshot taken after its publication. + // Keep that boundary across batches, including when the terminal event + // itself has already fallen out of the retained log. + if state.terminal_cursor.is_none() && page.target_closed.load(Ordering::SeqCst) { + state.terminal_cursor = Some(log.cursor()); + } ( log.oldest_seq(), log.entries_since_limited(*cursor, raw_limit), + if *cursor < log.oldest_seq() { + state.undispatched_active_dialogs(&log, *cursor..log.oldest_seq()) + } else { + BTreeMap::new() + }, ) }; + #[cfg(test)] + if entries.is_empty() { + let barrier = tests::page_event_stream_snapshot_barriers() + .lock() + .unwrap() + .remove(&(page as *const PageInner as usize)); + if let Some((entered, released)) = barrier { + entered.send(()).unwrap(); + released.await.unwrap(); + } + } let scanned_full_chunk = entries.len() >= raw_limit; if *cursor < oldest_seq { let dropped = oldest_seq - *cursor; - let Some(()) = with_live_page_retention(page, || { + { + let retention_gate = page.browser.client.retention_gate(); + let Some(_retention_guard) = retention_gate.lock_for_write() else { + batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); + return (batch, true); + }; + if (page.lifecycle.is_closing_or_closed() + || page.target_closed.load(Ordering::SeqCst)) + && !page.lifecycle.allows_events_during_close() + { + batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); + return (batch, true); + } *cursor = oldest_seq; + let terminal_cursor = state.terminal_cursor; *state = PageEventStreamState::for_page(page); + state.terminal_cursor = terminal_cursor; + state.ready = recovered_dialogs; requests.lock().unwrap().reset_after_overflow(oldest_seq); - }) else { - batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); - return (batch, true); - }; + } batch.push(page_event_envelope( *cursor, "_overflow", json!({ "dropped": dropped }), )); + append_ready_page_events(&mut batch, state, max_events); if batch.len() >= max_events { return (batch, false); } } for (seq, event) in entries { + if state.terminal_cursor.is_some_and(|end| seq >= end) { + break; + } if seq < *cursor { continue; } *cursor = seq.saturating_add(1); + if is_page_target_close_event(page, &event) { + state.terminal_cursor = Some(*cursor); + break; + } let retention_gate = page.browser.client.retention_gate(); let Some(_retention_guard) = retention_gate.lock_for_write() else { batch.push(page_event_envelope(*cursor, "_closed", Value::Null)); @@ -54417,6 +56537,9 @@ async fn wait_for_page_event_batch( return (batch, false); } } + if state.terminal_cursor.is_some_and(|end| *cursor >= end) { + continue; + } if !batch.is_empty() { return (batch, false); } @@ -54424,6 +56547,10 @@ async fn wait_for_page_event_batch( continue; } + if page.target_closed.load(Ordering::SeqCst) { + continue; + } + let now = tokio::time::Instant::now(); if now >= deadline { return (batch, false); @@ -54882,7 +57009,7 @@ fn page_worker_session_from_attachment(page: &PageInner, event: &Value) -> Optio state.arm_worker_resume_handoff(child_session_id, event_sequence); state .claim_worker_resume_handoff(child_session_id) - .then(|| child_session_id.to_string()) + .then_some(child_session_id.to_string()) }) .flatten() } @@ -54904,10 +57031,11 @@ fn remove_page_worker_session_from_detachment(page: &PageInner, event: &Value) - async fn process_console_wait_event( event: Value, event_cursor: &mut u64, - replay_start_cursor: u64, + _replay_start_cursor: u64, event_log: &Arc>, session_id: &str, replay_until: &HashMap, + replay_clock_samples: &HashMap>, event_kind: &str, page: Option>, mut page_state: Option<&mut PageEventStreamState>, @@ -54937,21 +57065,12 @@ async fn process_console_wait_event( *event_cursor = sequence.saturating_add(1); } if let Some(state) = page_state.as_mut() { - state.sync_replay_cutoffs_for_event(&event, event_log); + state.sync_replay_windows_for_event(&event, event_log); state.apply_target_transition(&event, session_id); } - let replay_cutoff = event_session_id.and_then(|event_session_id| { - page_state - .as_ref() - .and_then(|state| state.replay_until.get(event_session_id).copied()) - .or_else(|| replay_until.get(event_session_id).copied()) - }); - if matches!( - method, - Some("Runtime.consoleAPICalled") | Some("Runtime.exceptionThrown") - ) && replay_cutoff.is_some_and(|cutoff| { - event_sequence.is_some_and(|sequence| sequence < cutoff && sequence < replay_start_cursor) - }) { + let is_replay_history = + console_event_is_replay_history(&event, event_log, replay_until, replay_clock_samples); + if is_replay_history { return Ok(None); } @@ -55057,6 +57176,7 @@ async fn wait_for_console_event( session_id: &str, mut event_cursor: u64, replay_until: &HashMap, + replay_clock_samples: &HashMap>, event_kind: &str, page: Option>, page_state: &mut Option, @@ -55109,6 +57229,7 @@ async fn wait_for_console_event( &event_log, session_id, replay_until, + replay_clock_samples, event_kind, page.clone(), page_state.as_mut(), @@ -55149,6 +57270,7 @@ async fn wait_for_console_event( &event_log, session_id, replay_until, + replay_clock_samples, event_kind, page.clone(), page_state.as_mut(), From b298e4458fbf15a248924c33408a946d01d50938 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:52:33 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'tests/'=20?= =?UTF-8?q?with=20remote=20'tests/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/241 --- tests/dialog_overflow_pump.py | 109 +++ tests/test_rustwright_sync_api.py | 1128 +++++++++++++++++++++++++++-- 2 files changed, 1164 insertions(+), 73 deletions(-) create mode 100644 tests/dialog_overflow_pump.py diff --git a/tests/dialog_overflow_pump.py b/tests/dialog_overflow_pump.py new file mode 100644 index 0000000..7cc2d35 --- /dev/null +++ b/tests/dialog_overflow_pump.py @@ -0,0 +1,109 @@ +"""Browser-free pump exercise shared by native and Python regression pins.""" + +import asyncio +import threading +import warnings + +from rustwright.sync_api import Page +from rustwright.async_api import AsyncPage + + +def exercise_dialog_overflow_pump(stream, publish, async_mode, ownership, expected_overflows=2): + class Core: + def __init__(self): + self.dialog_actions = [] + + def combined_event_stream(self): + return stream + + def keyboard_primary_modifier(self): + return "Control" + + def is_closed(self): + return False + + def handle_dialog(self, accept, _prompt, _timeout): + self.dialog_actions.append(accept) + + async def run(): + core = Core() + page = Page(core, _start_event_pump=not async_mode) + wrapper = AsyncPage(page) if async_mode else None + entered, release, drained, claimed = (threading.Event() for _ in range(4)) + dialogs, preceding_actions = [], [] + + def on_console(message): + if message.text == "blocked": + entered.set() + assert release.wait(5) + else: + release.clear() + drained.set() + assert release.wait(5) + + def claim(dialog): + preceding_actions.append(list(core.dialog_actions)) + dialogs.append(dialog) + claimed.set() + return True + + async def async_listener(dialog): + claim(dialog._sync) + + # Avoid Runtime.enable: all events come from the supplied test transport. + page._event_handlers["console"] = [on_console] + page._event_handler_cursors[("console", id(on_console))] = 0 + waiter = None + if ownership == "listener": + if wrapper is None: + page.on("dialog", claim) + else: + wrapper.on("dialog", async_listener) + elif ownership == "waiter": + waiter = page.expect_event("dialog", predicate=claim, timeout=2000) + waiter.__enter__() + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + publish(0) + assert await asyncio.to_thread(entered.wait, 2) + publish(1) + assert core.dialog_actions == [] + release.set() + assert await asyncio.to_thread(drained.wait, 2) + if ownership != "unowned": + assert await asyncio.to_thread(claimed.wait, 2) + if waiter is not None: + waiter.__exit__(None, None, None) + waiter = None + assert preceding_actions == [[]] + assert core.dialog_actions == [] + else: + assert core.dialog_actions == [False] + # The native dialog stays open for the owner. Another overflow + # must not redispatch it or create an unowned fallback. + drained.clear() + publish(2) + release.set() + assert await asyncio.to_thread(drained.wait, 2) + page._maybe_fallback_pending_dialogs() + assert len(dialogs) == (0 if ownership == "unowned" else 1) + assert core.dialog_actions == ([False] if ownership == "unowned" else []) + if dialogs: + dispatch = dialogs[0]._dispatch + assert dispatch.captured if ownership == "waiter" else dispatch.handler_claims + await asyncio.to_thread(dialogs[0].accept) + assert core.dialog_actions == [True] + assert sum("event stream overflow" in str(w.message) for w in caught) == expected_overflows + finally: + release.set() + if waiter is not None: + waiter.__exit__(RuntimeError, RuntimeError("cleanup"), None) + page._stop_event_pump() + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + + asyncio.run(run()) diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 13159ef..a2f04b1 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -4595,6 +4595,26 @@ def test_context_close_closes_pages_before_context_close_event(playwright): ("context", True, True, True, True, False, False), ] browser.close() +def test_page_close_owned_context_only_for_browser_new_page(playwright): + browser = playwright.chromium.launch(headless=True) + try: + owned_page = browser.new_page() + owned_context = owned_page.context + owned_context_close_events = [] + owned_context.on("close", owned_context_close_events.append) + owned_page.close() + assert owned_page.is_closed() + assert owned_context.is_closed() + assert owned_context_close_events == [owned_context] + + shared_context = browser.new_context() + shared_page = shared_context.new_page() + shared_page.close() + assert shared_page.is_closed() + assert shared_context.is_closed() is False + shared_context.close() + finally: + browser.close() def test_new_context_download_behavior_failure_rolls_back_sync_context(): @@ -9258,29 +9278,80 @@ def wait_from_pump_thread(): assert "'console'" in outcome["context_error"] -def test_event_pump_overflow_warns_and_reconciles_bookkeeping_without_browser(): +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("owned", [False, True]) +@pytest.mark.parametrize("split_batch", [False, True]) +def test_event_pump_overflow_warns_and_reconciles_bookkeeping_without_browser(async_mode, owned, split_batch): from rustwright.sync_api import Page + from rustwright.async_api import AsyncPage - class Core: - def __init__(self): - self.dialog_calls = [] + async def run(): + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + wrapper = AsyncPage(page) if async_mode else None + page._network_idle_active_requests.add("request-1") + page._network_idle_last_activity = 0.0 + actions_at_listener = [] + + def listener(dialog): + actions_at_listener.append(list(core.dialog_actions)) + dialog.accept() + + async def async_listener(dialog): + actions_at_listener.append(list(core.dialog_actions)) + await dialog.accept() + + if owned: + if wrapper is None: + page.on("dialog", listener) + else: + wrapper.on("dialog", async_listener) + batch = [ + {"seq": 7, "kind": "_overflow", "payload": {"dropped": 7}}, + {"seq": 8, "kind": "dialog", "payload": {"type": "beforeunload", "message": "retained"}}, + {"seq": 9, "kind": "_closed", "payload": None}, + ] + try: + with pytest.warns(RuntimeWarning, match=r"dropped 7 event\(s\)"): + for part in ([batch[:1], batch[1:]] if split_batch else [batch]): + core.stream.batches.put((9, part)) + assert await asyncio.to_thread(core.dialog_handled.wait, 2) + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + assert page._network_idle_active_requests == set() + assert page._network_idle_last_activity > 0 + assert actions_at_listener == ([[]] if owned else []) + assert core.dialog_actions == [owned] + finally: + page._stop_event_pump() + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) - def handle_dialog(self, *args): - self.dialog_calls.append(args) + asyncio.run(run()) - owner = Page.__new__(Page) - owner._network_idle_condition = threading.Condition() - owner._network_idle_active_requests = {"request-1"} - owner._network_idle_last_activity = 0.0 - owner._core = Core() - owner._default_timeout = 30_000.0 - with pytest.warns(RuntimeWarning, match=r"dropped 7 event\(s\)"): - owner._reconcile_event_stream_overflow({"dropped": 7}) +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("ownership", ["unowned", "listener", "waiter"]) +def test_recovered_dialog_uses_ownership_dispatch_without_browser(async_mode, ownership): + from dialog_overflow_pump import exercise_dialog_overflow_pump + + stream = _LifecycleEventStream() + + def publish(phase): + if phase == 0: + stream.emit("console", {"type": "log", "text": "blocked"}) + return + stream.emit("_overflow", {"dropped": 7}) + if phase == 1: + stream.emit("dialog", {"type": "beforeunload", "message": "evicted"}) + stream.emit("console", {"type": "log", "text": "drained"}) - assert owner._network_idle_active_requests == set() - assert owner._network_idle_last_activity > 0 - assert owner._core.dialog_calls == [(False, None, 500.0)] + exercise_dialog_overflow_pump(stream, publish, async_mode, ownership) def test_page_network_lifecycle_order_and_close_stops_event_pump(browser, http_server): @@ -24887,6 +24958,42 @@ def test_dialog_without_handler_is_auto_dismissed_by_page_event_pump(page): assert page.evaluate("document.body.dataset.after") == "done" +def test_dialog_predicate_exception_is_propagated(page): + page.set_content("
dialog predicate error
") + + def predicate(_dialog): + raise ZeroDivisionError("dialog predicate failed") + + with pytest.raises(ZeroDivisionError, match="dialog predicate failed"): + with page.expect_event("dialog", predicate, timeout=1_000): + page.evaluate("() => alert('predicate error')") + + +def test_dialog_predicate_rejection_keeps_unmatched_dialog_auto_dismissal(page): + page.set_content("
dialog predicate
") + + with page.expect_event("dialog", lambda dialog: dialog.message == "wanted", timeout=3_000) as info: + page.evaluate( + """() => { + setTimeout(() => alert("unwanted"), 0); + setTimeout(() => alert("wanted"), 100); + }""" + ) + + info.value.dismiss() + + +def test_wait_for_event_console_repeated_text_only_matches_new_event(page): + repeated_text = "same console payload" + page.evaluate("(text) => console.log(text)", repeated_text) + wait_until(lambda: repeated_text in [message.text for message in page.console_messages(filter="all")]) + + with page.expect_event("console", lambda message: message.text == repeated_text, timeout=3_000) as info: + page.evaluate("(text) => console.log(text)", repeated_text) + + assert info.value.text == repeated_text + + def test_page_close_run_before_unload_emits_beforeunload_dialog(browser): context = browser.new_context() page = context.new_page() @@ -24910,6 +25017,337 @@ def test_page_close_run_before_unload_emits_beforeunload_dialog(browser): context.close() +def test_page_close_tail_failure_is_reported_to_waiters_and_retry_skips_native_close(): + from rustwright.sync_api import Page + + class EventStream: + def close(self): + return None + + class Core: + def __init__(self): + self.close_calls = 0 + + def combined_event_stream(self): + return EventStream() + + def keyboard_primary_modifier(self): + return "Control" + + def close(self, *_args): + self.close_calls += 1 + + core = Core() + page = Page(core, _start_event_pump=False) + fail_once = [True] + + def on_close(_page): + if fail_once[0]: + fail_once[0] = False + raise RuntimeError("close tail failed") + + page.on("close", on_close) + with pytest.raises(RuntimeError, match="close tail failed"): + page.close() + generation = page._rustwright_sync_close_generation + assert page._rustwright_sync_close_state == "open" + assert page._rustwright_sync_close_outcomes[generation].error is not None + assert core.close_calls == 1 + + page.close() + assert page.is_closed() + assert core.close_calls == 1 +class _LifecycleEventStream: + """In-memory transport for the production sync and async page pumps.""" + + def __init__(self): + import queue + + self.batches = queue.Queue() + self.producer_cursor = 0 + self.cursor = 0 + self.terminal = False + self.pending_terminal = False + + def emit(self, kind, payload=None): + self.producer_cursor += 1 + self.batches.put((self.producer_cursor, [{"seq": self.producer_cursor, "kind": kind, "payload": payload}])) + + def _read_batch(self): + import queue + + try: + self.cursor, batch = self.batches.get(timeout=1) + except queue.Empty: + return "[]" + self.pending_terminal = batch[-1]["kind"] == "_closed" + return json.dumps(batch) + + def wait_batch(self, _timeout, _limit): + batch = self._read_batch() + # The sync binding publishes closure on read; async waits for ack_batch. + self.terminal = self.pending_terminal + return batch + + async def wait_batch_async(self, timeout, limit): + return await asyncio.to_thread(self._read_batch) + + def ack_batch(self): + self.terminal = self.pending_terminal + + def rollback_batch(self): + pass + + def event_cursor(self): + return self.cursor + + def is_closed(self): + return self.terminal + + def close(self): + self.emit("_closed") + + +class _LifecycleCore: + def __init__(self): + self.stream = _LifecycleEventStream() + self.close_calls = 0 + self.dialog_actions = [] + self.dialog_handled = threading.Event() + + def combined_event_stream(self): + return self.stream + + def keyboard_primary_modifier(self): + return "Control" + + def event_cursor(self): + return self.stream.producer_cursor + + def is_closed(self): + # Deliberately models the former lifecycle predicate: command completion + # must not let the production barrier bypass terminal stream receipt. + return self.close_calls > 0 + + def close(self, *_args): + self.close_calls += 1 + + async def close_async(self, *args): + self.close(*args) + + def handle_dialog(self, accept, _prompt, _timeout): + self.dialog_actions.append(accept) + self.dialog_handled.set() + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_page_close_har_export_failure_preserves_retryable_cleanup(monkeypatch, async_mode): + import rustwright.sync_api as sync_api + from rustwright.async_api import AsyncPage + + async def run(): + core = _LifecycleCore() + page = sync_api.Page(core, _start_event_pump=not async_mode) + wrapper = AsyncPage(page) if async_mode else None + page._har_recordings.append(("retry.har", None, "embed", "minimal")) + received = threading.Event() + messages = [] + + def on_console(message): + messages.append(message.text) + received.set() + + page._event_handlers["console"] = [on_console] + page._event_handler_cursors[("console", id(on_console))] = 0 + fail = [True] + writes = [] + + def receive_console(text): + received.clear() + core.stream.emit("console", {"type": "log", "text": text, "args": []}) + assert received.wait(2), "live event pump stopped during pre-native export" + + def write_har(path, _pages, _url_filter, *, content_mode, har_mode): + if fail[0]: + # Force two separate pump iterations while export is in progress. + receive_console("during export 1") + receive_console("during export 2") + raise OSError("output directory is unwritable") + writes.append((path, content_mode, har_mode)) + + monkeypatch.setattr(sync_api, "_write_har", write_har) + + async def close(): + if wrapper is not None: + await wrapper.close() + else: + await asyncio.to_thread(page.close) + + try: + with pytest.raises(OSError, match="unwritable"): + await close() + assert core.close_calls == 0 + assert page._rustwright_sync_close_state == "open" + assert page._har_recordings + assert page._rustwright_sync_close_native_disposed is False + await asyncio.to_thread(receive_console, "after failure") + core.stream.emit("dialog", {"type": "alert", "message": "unowned after failure"}) + assert await asyncio.to_thread(core.dialog_handled.wait, 2) + assert core.dialog_actions == [False] + fail[0] = False + await close() + assert core.close_calls == 1 + assert writes == [("retry.har", "embed", "minimal")] + assert messages == ["during export 1", "during export 2", "after failure"] + finally: + page._stop_event_pump() + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + elif page._event_pump_thread is not None: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("fail_first", [False, True]) +def test_owned_page_purges_storage_and_service_workers_before_native_close(monkeypatch, async_mode, fail_first): + from rustwright.sync_api import BrowserContext, Page + from rustwright.async_api import AsyncPage + + async def run(): + storage = {"local": "private", "session": "private"} + registrations = ["/sw.js"] + cleared_origins = [] + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + wrapper = AsyncPage(page) if async_mode else None + + class Context: + _core = None + _browser = object() + _storage_state_origins = set() + _rustwright_sync_close_state = "open" + + def __init__(self): + self._pages = [page] + self.closed = False + self.fail_purge = fail_first + + def _cleanup_default_context_state(self): + if self.fail_purge: + self.fail_purge = False + raise OSError("purge preparation failed") + BrowserContext._cleanup_default_context_state(self) + + def _send_default_context_browser_command(self, method, params=None): + if method == "Storage.clearDataForOrigin": + cleared_origins.append(params["origin"]) + + def close(self): + self.closed = True + + context = Context() + page._context = context + page._owns_context = True + page._rustwright_sync_close_default_context_cleaned = False + + def evaluate(expression): + if core.close_calls: + raise Error("Target closed") + if expression == "location.origin": + return "https://example.test" + assert "registration.unregister()" in expression + assert "localStorage.clear()" in expression + assert "sessionStorage.clear()" in expression + storage.clear() + registrations.clear() + + monkeypatch.setattr(page, "evaluate", evaluate) + original_close = core.close + + def native_close(*args): + assert not storage and not registrations + assert cleared_origins == ["https://example.test"] + original_close(*args) + + monkeypatch.setattr(core, "close", native_close) + async def close(): + if wrapper is not None: + await wrapper.close() + else: + await asyncio.to_thread(page.close) + + try: + if fail_first: + with pytest.raises(OSError, match="purge preparation failed"): + await close() + assert core.close_calls == 0 + assert not page._rustwright_sync_close_default_context_cleaned + assert not page._event_pump_stopped + assert storage and registrations + await close() + assert context.closed + assert core.close_calls == 1 + assert not storage and not registrations + assert page._rustwright_sync_close_default_context_cleaned + finally: + page._stop_event_pump() + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + + asyncio.run(run()) + + +def test_owned_default_context_page_close_isolates_origin_state(browser, http_server, monkeypatch): + # Exercise the logical default-context fallback on a real browser. No native + # incognito context can hide a missed pre-disposal purge in this test. + native = browser._core + + class DefaultContextCore: + def single_process_fallback(self): + return True + + def __getattr__(self, name): + return getattr(native, name) + + monkeypatch.setattr(browser, "_core", DefaultContextCore()) + first = browser.new_page() + assert first.context._core is None + first.goto(http_server) + first.evaluate("""async () => { + await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + localStorage.setItem('owned-page-secret', 'private'); + sessionStorage.setItem('owned-page-secret', 'private'); + }""") + assert first.evaluate("async () => (await navigator.serviceWorker.getRegistrations()).length") > 0 + first.close() + second = browser.new_page() + try: + second.goto(http_server) + assert second.evaluate("localStorage.getItem('owned-page-secret')") is None + assert second.evaluate("sessionStorage.getItem('owned-page-secret')") is None + assert second.evaluate("async () => (await navigator.serviceWorker.getRegistrations()).length") == 0 + finally: + second.close() + + +@pytest.mark.parametrize("clock_mode", ["installed", "throwing"]) +def test_console_capture_ignores_application_clock_override(page, clock_mode): + if clock_mode == "installed": + page.clock.install(time="2100-01-01T00:00:00Z") + else: + page.evaluate("() => { Date.now = () => { throw 9000000000000000; }; }") + with page.expect_console_message(lambda message: message.text == "trusted clock live") as message: + page.evaluate("console.log('trusted clock live')") + assert message.value.text == "trusted clock live" + + def test_close_reason_option_validation_matches_playwright(browser): page = browser.new_page() try: @@ -25195,6 +25633,46 @@ def test_page_crash_event_for_chromium_target_crash(browser): assert seen == [True] with pytest.raises(TimeoutError, match='Timeout 10ms exceeded while waiting for event "crash"'): page.wait_for_event("crash", timeout=10) +@pytest.mark.parametrize("async_mode", [False, True]) +def test_page_goto_crash_url_maps_aborted_without_crash_listener(monkeypatch, async_mode): + from rustwright.sync_api import Page + + class FakeCore(_LifecycleCore): + def goto(self, *_args): + raise Error("Page.goto: net::ERR_ABORTED at chrome://crash") + + async def goto_async(self, *args): + self.goto(*args) + + page = Page(FakeCore(), _start_event_pump=False) + assert not page._crashed + page._context = None + page._default_timeout = 1_000.0 + page._closed = False + page._closing = False + monkeypatch.setattr(Page, "_resolve_url", lambda _self, value: value) + monkeypatch.setattr(Page, "_mark_context_pageload_pending", lambda _self: None) + monkeypatch.setattr(Page, "_mark_request_cookie_sync_required", lambda _self: None) + monkeypatch.setattr(Page, "_trace_begin_action", lambda _self, *_args, **_kwargs: None) + monkeypatch.setattr(Page, "_trace_end_action", lambda _self, *_args, **_kwargs: None) + monkeypatch.setattr(Page, "_page_cdp_event_generation", lambda _self, *_args: None) + monkeypatch.setattr(Page, "_prepare_navigation", lambda _self: (0, None)) + monkeypatch.setattr(Page, "_mark_navigation_history_boundary", lambda _self, *_args: None) + monkeypatch.setattr(Page, "_clear_context_pageload_pending", lambda _self: None) + monkeypatch.setattr(Page, "_download_event_waiter", lambda _self: None) + monkeypatch.setattr(Page, "_slow_mo", lambda _self: None) + + with pytest.raises(Error, match="Page crashed"): + if async_mode: + from rustwright.async_api import AsyncPage + + wrapper = object.__new__(AsyncPage) + wrapper._sync = page + asyncio.run(wrapper.goto("chrome://crash")) + else: + page.goto("chrome://crash") + + def test_page_event_waiters_reject_on_page_crash(browser): @@ -33039,7 +33517,6 @@ async def run() -> None: assert pump is not None pump.cancel() await asyncio.gather(pump, return_exceptions=True) - await asyncio.sleep(0) retry = page._sync._event_stream.wait_batch_async(1.0, 64) assert isinstance(retry, asyncio.Future) @@ -33050,6 +33527,48 @@ async def run() -> None: asyncio.run(run()) +def test_async_event_pump_cancellation_waits_for_lease_restoration(): + async def run(): + from rustwright.async_api import AsyncPage + + started = asyncio.Event() + restoring = asyncio.Event() + restored = asyncio.Event() + + class FakeEventStream: + async def wait_batch_async(self, _timeout, _limit): + if started.is_set(): + assert restored.is_set(), "retry acquired an unrestored lease" + return "[]" + started.set() + await asyncio.Future() + + async def cancel_wait_async(self): + restoring.set() + await restored.wait() + + class FakePage: + _event_stream = FakeEventStream() + + def _event_listeners_active(self): + return True + + page = object.__new__(AsyncPage) + page._sync = FakePage() + pump = asyncio.create_task(page._event_pump()) + await asyncio.wait_for(started.wait(), 2) + pump.cancel() + await asyncio.wait_for(restoring.wait(), 2) + assert not pump.done() + # Repeated cancellation must still leave the event loop free to restore. + pump.cancel() + restored.set() + await asyncio.wait_for(asyncio.gather(pump, return_exceptions=True), 2) + assert await page._sync._event_stream.wait_batch_async(1.0, 64) == "[]" + + asyncio.run(run()) + + def test_async_event_pump_closed_batch_is_terminal_and_acknowledged(): async def run() -> None: import rustwright.async_api as async_api @@ -33075,10 +33594,15 @@ class FakePage: def __init__(self): self._event_stream = FakeEventStream() self.stop_calls = 0 + self.cursor_notes = 0 def _event_listeners_active(self): return True + def _note_event_batch_consumed(self, terminal): + assert terminal + self.cursor_notes += 1 + def _stop_event_pump(self): self.stop_calls += 1 @@ -33093,6 +33617,7 @@ def _stop_event_pump(self): pump.cancel() await asyncio.gather(pump, return_exceptions=True) + assert sync_page.cursor_notes == 1 assert sync_page._event_stream.wait_calls == 1 assert sync_page._event_stream.ack_calls == 1 assert sync_page._event_stream.rollback_calls == 0 @@ -33101,73 +33626,317 @@ def _stop_event_pump(self): asyncio.run(run()) -def test_async_native_close_waits_for_beforeunload_dialog_dispatch(): - async def run() -> None: - import rustwright.async_api as async_api +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("outcome", ["listener", "unowned", "terminal"]) +@pytest.mark.parametrize("preparation_dialog", ["alert", "beforeunload"]) +def test_page_close_production_barrier_waits_for_post_response_receipt(async_mode, outcome, preparation_dialog): + from rustwright.sync_api import Page + from rustwright.async_api import AsyncPage - order = [] + async def run(): + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + page._default_timeout = 30_000 + wrapper = AsyncPage(page) if async_mode else None + waiting = threading.Event() + dispatched = threading.Event() seen = [] - class FakeCore: - def __init__(self, owner): - self.owner = owner + class BarrierCondition(threading.Condition): + def wait(self, timeout=None): + waiting.set() + return super().wait(timeout) - def close_async(self, _timeout, _run_before_unload): - async def close(): - order.append("close-command") + page._event_dispatch_condition = BarrierCondition(page._event_dispatch_lock) + handle_dialog = page._handle_dialog_event - async def dispatch_dialog(): - await asyncio.sleep(0.02) - if self.owner._event_pump_stopped: - return - for handler in self.owner._event_handlers["dialog"]: - handler("beforeunload") - self.owner._dialog_dispatch_count += 1 - order.append("dialog") + def note_dispatch(payload): + handle_dialog(payload) + dispatched.set() - self.owner.dispatch_task = asyncio.create_task(dispatch_dialog()) + page._handle_dialog_event = note_dispatch + purge = page._purge_owned_default_context - return close() + def prepare(): + core.stream.emit("dialog", {"type": preparation_dialog, "message": "preparation"}) + assert dispatched.wait(2), "preparation dialog was not dispatched" + purge() - class FakePage: - def __init__(self): - self._closed_reason = None - self._owns_context = False - self._context = None - self._default_timeout = 1_000.0 - self._event_handlers = {"dialog": [seen.append]} - self._dialog_dispatch_count = 0 - self._event_pump_stopped = False - self._closed = False - self._closing = False - self.dispatch_task = None - self.release_calls = 0 - self._core = FakeCore(self) + page._purge_owned_default_context = prepare - def _stop_event_pump(self): - self._event_pump_stopped = True - order.append("stop-pump") + def unexpected_timeout_fallback(): + raise AssertionError("close barrier reached its fallback deadline") - def _mark_owned_cdp_sessions_closed(self): - pass + page._fallback_close_dialog = unexpected_timeout_fallback + if outcome in {"listener", "terminal"}: + def on_dialog(dialog): + seen.append(dialog.type) + dialog.dismiss() + page.on("dialog", on_dialog) - def _release_memory_buffers(self): - self.release_calls += 1 - order.append("release") + close = asyncio.create_task( + wrapper.close(run_before_unload=True) if wrapper is not None + else asyncio.to_thread(page.close, run_before_unload=True) + ) + try: + assert await asyncio.to_thread(waiting.wait, 2), "barrier returned on command response" + assert core.close_calls == 1 + assert not close.done() + assert not page._event_pump_stopped + # An unrelated dialog after the boundary must not satisfy it either. + waiting.clear() + dispatched.clear() + core.stream.emit("dialog", {"type": "alert", "message": "after response"}) + assert await asyncio.to_thread(dispatched.wait, 2) + assert await asyncio.to_thread(waiting.wait, 2), "alert satisfied the beforeunload barrier" + assert not close.done() + if outcome == "terminal": + core.stream.emit("_closed") + else: + core.stream.emit("dialog", {"type": "beforeunload", "message": "", "defaultValue": ""}) + await asyncio.wait_for(close, 10) + assert seen == ( + [preparation_dialog, "alert"] + (["beforeunload"] if outcome == "listener" else []) + if outcome in {"listener", "terminal"} else [] + ) + assert core.dialog_actions == ([False, False] if outcome == "terminal" else [False, False, False]) + assert page.is_closed() + finally: + page._stop_event_pump() + await asyncio.gather(close, return_exceptions=True) + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() - sync_page = FakePage() - page = object.__new__(async_api.AsyncPage) - page._sync = sync_page - page._event_pump_task = None + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_page_close_terminal_batch_requires_python_consumption(async_mode): + from rustwright.sync_api import Page + from rustwright.async_api import AsyncPage + + async def run(): + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + wrapper = AsyncPage(page) if async_mode else None + waiting = threading.Event() + first_consumed = threading.Event() + final_console_entered = threading.Event() + release_console = threading.Event() + seen = [] + + class BarrierCondition(threading.Condition): + def wait(self, timeout=None): + waiting.set() + return super().wait(timeout) + + page._event_dispatch_condition = BarrierCondition(page._event_dispatch_lock) + note_consumed = page._note_event_batch_consumed - await page._close_native(None, True) - order.append("close-complete") - assert sync_page.dispatch_task is not None - await sync_page.dispatch_task - assert seen == ["beforeunload"] + def note(terminal): + note_consumed(terminal) + if not terminal: + first_consumed.set() - assert sync_page.release_calls == 1 - assert order == ["close-command", "dialog", "stop-pump", "release", "close-complete"] + page._note_event_batch_consumed = note + + def on_console(message): + seen.append(message.text) + if message.text == "terminal batch": + final_console_entered.set() + assert release_console.wait(5) + + page._event_handlers["console"] = [on_console] + page._event_handler_cursors[("console", id(on_console))] = 0 + page.on("dialog", lambda dialog: seen.append(dialog.type)) + close = asyncio.create_task( + wrapper.close(run_before_unload=True) if wrapper is not None + else asyncio.to_thread(page.close, run_before_unload=True) + ) + try: + assert await asyncio.to_thread(waiting.wait, 2) + core.stream.producer_cursor = 100 + core.stream.batches.put((100, [ + {"seq": index, "kind": "console", "payload": {"type": "log", "text": str(index)}} + for index in range(64) + ])) + assert await asyncio.to_thread(first_consumed.wait, 2) + core.stream.batches.put((100, [ + {"seq": 64, "kind": "console", "payload": {"type": "log", "text": "terminal batch"}}, + {"seq": 65, "kind": "dialog", "payload": {"type": "beforeunload", "message": "retained"}}, + {"seq": 100, "kind": "_closed", "payload": None}, + ])) + assert await asyncio.to_thread(final_console_entered.wait, 2) + assert core.stream.event_cursor() == 100 + assert core.stream.is_closed() is (not async_mode) + # Recheck while the preceding consumption and final read share a + # cursor, and Python has not yet reached the retained dialog. + with page._event_dispatch_condition: + waiting.clear() + page._event_dispatch_condition.notify_all() + assert await asyncio.to_thread(waiting.wait, 2), "native read was mistaken for consumption" + assert not close.done() + assert not page._event_pump_stopped + release_console.set() + await asyncio.wait_for(close, 5) + assert seen == [str(index) for index in range(64)] + ["terminal batch", "beforeunload"] + assert core.dialog_actions == [] + finally: + release_console.set() + page._stop_event_pump() + await asyncio.wait_for(asyncio.gather(close, return_exceptions=True), 5) + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("terminal", [False, True]) +def test_page_close_timeout_keeps_queued_unowned_dialog_retryable(async_mode, terminal): + from rustwright.sync_api import Page + from rustwright.async_api import AsyncPage + + async def run(): + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + page._default_timeout = 100 + wrapper = AsyncPage(page) if async_mode else None + entered = threading.Event() + release = threading.Event() + waiting = threading.Event() + close_actions = [] + + class BarrierCondition(threading.Condition): + def wait(self, timeout=None): + waiting.set() + return super().wait(timeout) + + page._event_dispatch_condition = BarrierCondition(page._event_dispatch_lock) + + def on_console(_message): + entered.set() + assert release.wait(5) + + page._event_handlers["console"] = [on_console] + page._event_handler_cursors[("console", id(on_console))] = 0 + page.on("close", lambda _page: close_actions.append(list(core.dialog_actions))) + + async def close(**kwargs): + if wrapper is not None: + await wrapper.close(**kwargs) + else: + await asyncio.to_thread(page.close, **kwargs) + + attempt = None + try: + core.stream.emit("console", {"type": "log", "text": "blocking callback"}) + assert await asyncio.to_thread(entered.wait, 2) + attempt = asyncio.create_task(close(run_before_unload=True)) + assert await asyncio.to_thread(waiting.wait, 2) + assert core.close_calls == 1 + core.stream.emit("dialog", {"type": "beforeunload", "message": "after response"}) + if terminal: + core.stream.emit("_closed") + with pytest.raises(TimeoutError, match="beforeunload event dispatch; retry close"): + await asyncio.wait_for(attempt, 2) + assert not page.is_closed() + assert not page._event_pump_stopped + assert not page._rustwright_sync_close_cleanup_complete + assert core.dialog_actions == [] + assert close_actions == [] + # Retrying with default arguments must resume the same dispatch phase. + with pytest.raises(TimeoutError, match="beforeunload event dispatch; retry close"): + await asyncio.wait_for(close(), 2) + assert core.close_calls == 1 + assert not page._event_pump_stopped + release.set() + assert await asyncio.to_thread(core.dialog_handled.wait, 2) + await asyncio.wait_for(close(), 3) + assert core.dialog_actions == [False] + assert close_actions == [[False]] + assert page.is_closed() + assert core.close_calls == 1 + finally: + release.set() + page._stop_event_pump() + if attempt is not None: + await asyncio.wait_for(asyncio.gather(attempt, return_exceptions=True), 3) + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() + + asyncio.run(run()) + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_close_barrier_timeout_preserves_dialog_listener_claim(async_mode): + from rustwright.sync_api import Page + from rustwright.async_api import AsyncPage + + async def run(): + core = _LifecycleCore() + page = Page(core, _start_event_pump=not async_mode) + page._default_timeout = 100 + wrapper = AsyncPage(page) if async_mode else None + entered = threading.Event() + released = threading.Event() + finished = threading.Event() + dialogs = [] + + def listener(dialog): + dialogs.append(dialog) + entered.set() + try: + assert released.wait(5) + finally: + finished.set() + + async def async_listener(dialog): + dialogs.append(dialog._sync) + entered.set() + try: + assert await asyncio.to_thread(released.wait, 5) + finally: + finished.set() + + if wrapper is None: + page.on("dialog", listener) + else: + wrapper.on("dialog", async_listener) + try: + core.stream.emit("dialog", {"type": "beforeunload", "message": "owned"}) + assert await asyncio.to_thread(entered.wait, 2) + assert dialogs[0]._dispatch.handler_claims + # Exercise the production timeout branch even when async handler + # scheduling has already advanced the beforeunload dispatch count. + result = await asyncio.wait_for( + asyncio.to_thread(page._wait_for_close_event_dispatch_or_fallback), 2 + ) + assert result is False + assert not finished.is_set() + assert core.dialog_actions == [] + assert dialogs[0]._dispatch.handler_claims + released.set() + assert await asyncio.to_thread(finished.wait, 2) + page._maybe_fallback_pending_dialogs() + assert core.dialog_actions == [] + finally: + released.set() + page._stop_event_pump() + if wrapper is not None: + await asyncio.wait_for(wrapper._event_pump_task, 2) + else: + await asyncio.to_thread(page._event_pump_thread.join, 2) + assert not page._event_pump_thread.is_alive() asyncio.run(run()) @@ -33219,6 +33988,30 @@ async def run() -> None: assert disconnected == [True] asyncio.run(run()) +def test_async_page_close_owned_context_only_for_browser_new_page(): + async def run() -> None: + from playwright.async_api import async_playwright + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + owned_page = await browser.new_page() + owned_context = owned_page.context + owned_context_close_events = [] + owned_context.on("close", owned_context_close_events.append) + await owned_page.close() + assert owned_page.is_closed() + assert owned_context.is_closed() + assert owned_context_close_events == [owned_context] + + shared_context = await browser.new_context() + shared_page = await shared_context.new_page() + await shared_page.close() + assert shared_page.is_closed() + assert shared_context.is_closed() is False + await shared_context.close() + await browser.close() + + asyncio.run(run()) def test_async_persistent_context_and_browser_concurrent_close_share_one_path(tmp_path: Path): @@ -48572,6 +49365,42 @@ def listener(_event): assert len(completed) == 2 +def test_event_waiter_publish_wins_timeout_interleaving(monkeypatch): + import rustwright.sync_api as sync_api + from rustwright.sync_api import Page, _ListenerEventContextManager + + class Core: + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + page = Page(Core(), _start_event_pump=False) + page._ensure_console_thread = lambda: None + page._attach_existing_worker_console_propagation = lambda: None + page._ensure_worker_thread = lambda: None + event_info = _ListenerEventContextManager(page, "console", lambda _event: True, 1_000) + event_info.__enter__() + wait_entered = threading.Event() + published = threading.Event() + + def wait_then_race(*_args, **_kwargs): + wait_entered.set() + dispatch = threading.Thread( + target=lambda: (event_info._handler(object()), published.set()), + daemon=True, + ) + dispatch.start() + assert published.wait(2) + dispatch.join(timeout=2) + raise TimeoutError("synthetic timeout after publish") + + monkeypatch.setattr(sync_api, "_wait_for_ready_event_or_owner_close", wait_then_race) + event_info.__exit__(None, None, None) + assert wait_entered.is_set() + assert event_info.value is not None + def test_page_console_listener_ignores_event_log_history_before_registration(): from rustwright.sync_api import Page @@ -49491,7 +50320,80 @@ def handler(_dialog): assert core.dismissals == [(False, None, page._default_timeout)] -def test_dialog_handler_that_returns_without_handling_is_auto_dismissed_once(): +def test_dialog_handler_exception_during_action_operation_is_auto_dismissed_once(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + core = Core() + page = Page(core, _start_event_pump=False) + + def handler(_dialog): + raise RuntimeError("action dialog handler failed") + + page.on("dialog", handler) + release_operation = page._begin_dialog_operation() + try: + page._handle_dialog_event({"type": "alert", "message": "raises during action"}) + finally: + release_operation() + assert core.dismissals == [(False, None, page._default_timeout)] + + +def test_async_dialog_handler_exception_during_action_operation_is_auto_dismissed_once(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + async def run(): + core = Core() + page = Page(core, _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = asyncio.get_running_loop() + owner._event_handler_wrappers = [] + + async def handler(_dialog): + raise RuntimeError("async action dialog handler failed") + + wrapped = _wrap_async_event_handler(owner, "dialog", handler) + page.on("dialog", wrapped) + release_operation = page._begin_dialog_operation() + try: + page._handle_dialog_event({"type": "alert", "message": "async raises during action"}) + finally: + release_operation() + for _ in range(3): + await asyncio.sleep(0) + assert core.dismissals == [(False, None, page._default_timeout)] + + asyncio.run(run()) + + +def test_dialog_handler_that_returns_without_handling_remains_open(): from rustwright.sync_api import Page class Core: @@ -49511,7 +50413,42 @@ def handle_dialog(self, accepted, prompt_text, timeout_ms): page = Page(core, _start_event_pump=False) page.on("dialog", lambda _dialog: None) page._handle_dialog_event({"type": "alert", "message": "unhandled"}) - assert core.dismissals == [(False, None, page._default_timeout)] + assert core.dismissals == [] + + +def test_dialog_handler_claims_are_retained_per_sync_handler(): + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + core = Core() + page = Page(core, _start_event_pump=False) + + def returns(_dialog): + return None + + def raises(_dialog): + raise RuntimeError("second handler failed") + + page.on("dialog", returns) + page.on("dialog", raises) + release_operation = page._begin_dialog_operation() + try: + page._handle_dialog_event({"type": "alert", "message": "two handlers"}) + finally: + release_operation() + assert core.dismissals == [] def test_async_dialog_handler_cancellation_after_activation_is_auto_dismissed_once(): @@ -49557,6 +50494,51 @@ async def handler(_dialog): asyncio.run(run()) +def test_dialog_handler_claims_are_retained_per_async_handler(): + from rustwright.async_api import _wrap_async_event_handler + from rustwright.sync_api import Page + + class Core: + def __init__(self): + self.dismissals = [] + + def combined_event_stream(self): + return object() + + def keyboard_primary_modifier(self): + return "Control" + + def handle_dialog(self, accepted, prompt_text, timeout_ms): + self.dismissals.append((accepted, prompt_text, timeout_ms)) + + async def run(): + core = Core() + page = Page(core, _start_event_pump=False) + owner = type("AsyncOwner", (), {})() + owner._sync = page + owner._loop = asyncio.get_running_loop() + owner._event_handler_wrappers = [] + + async def returns(_dialog): + return None + + async def raises(_dialog): + raise RuntimeError("second async handler failed") + + page.on("dialog", _wrap_async_event_handler(owner, "dialog", returns)) + page.on("dialog", _wrap_async_event_handler(owner, "dialog", raises)) + release_operation = page._begin_dialog_operation() + try: + page._handle_dialog_event({"type": "alert", "message": "two async handlers"}) + finally: + release_operation() + for _ in range(3): + await asyncio.sleep(0) + assert core.dismissals == [] + + asyncio.run(run()) + + def test_worker_interest_turns_off_after_last_console_or_worker_listener(): from rustwright.sync_api import Page From 8331d04a69522a1eeb1b98b997238e7455ced854 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:52:33 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'tools/'=20?= =?UTF-8?q?with=20remote=20'tools/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/241 --- tools/generate_async_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/generate_async_api.py b/tools/generate_async_api.py index 03f2013..16911c0 100644 --- a/tools/generate_async_api.py +++ b/tools/generate_async_api.py @@ -125,6 +125,7 @@ "_await_native_action", "_await_cleanup_completion", "_single_flight_close", + "_single_flight_close.owned_cleanup", "_wait_for_page_creations", "_native_context_page", "_finish_native_page",