From b49e70ef865a7904b4a9c082c50f3bb68397ea89 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:44:21 +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/237 --- python/rustwright/_async_generated.py | 2 +- python/rustwright/async_api.py | 231 +++++-- python/rustwright/sync_api.py | 894 +++++++++++++++++++++----- 3 files changed, 922 insertions(+), 205 deletions(-) diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index 54dcfb9..35b3133 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: 42a5e130e3fa8f0641c1c97f83cea4f088fa5b29716a38cf81998f97eb4c06c8 +# sync_api.py sha256: 86dbd1ae4130f44b5d7fdcc0756a6b0c36f5aa57b75e7e4249f64142e29a49c3 from __future__ import annotations from pathlib import Path diff --git a/python/rustwright/async_api.py b/python/rustwright/async_api.py index aa20866..b95c158 100644 --- a/python/rustwright/async_api.py +++ b/python/rustwright/async_api.py @@ -80,6 +80,9 @@ _EVENT_DISPATCH_SEQUENCE, _json, _is_ignorable_close_error, + _sync_close_wait_deadline, + _raise_close_wait_timeout, + _update_sync_cleanup_complete, _navigation_timeout_for_method, _normalize_action_boolean, _normalize_lifecycle_state, @@ -92,7 +95,7 @@ _options_from_explicit_kwargs, _response_from_payload, _translate_error, - _validate_timeout_value, + _write_har, _CONSOLE_HISTORY_BUFFER, _PAGE_ERROR_HISTORY_BUFFER, ) @@ -266,8 +269,33 @@ async def _await_cleanup_completion(awaitable: Any) -> Any: 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.""" - state = getattr(sync_obj, "_rustwright_async_close_state", _CLOSE_OPEN) - task = getattr(sync_obj, "_rustwright_async_close_task", None) + condition = getattr(sync_obj, "_rustwright_sync_close_condition", None) + 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 + return + 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: @@ -277,21 +305,14 @@ async def _single_flight_close(sync_obj: Any, cleanup: Callable[[], Any]) -> Non return task = asyncio.create_task(cleanup()) - sync_obj._rustwright_async_close_state = _CLOSE_CLOSING - sync_obj._rustwright_async_close_task = task + write_state(_CLOSE_CLOSING, task) try: await _await_cleanup_completion(task) except BaseException: - if task.done() and not task.cancelled() and task.exception() is None: - sync_obj._rustwright_async_close_state = _CLOSE_CLOSED - sync_obj._rustwright_async_close_task = None - else: - sync_obj._rustwright_async_close_state = _CLOSE_OPEN - sync_obj._rustwright_async_close_task = None + write_state(_CLOSE_CLOSED if task.done() and not task.cancelled() and task.exception() is None else _CLOSE_OPEN, None) raise else: - sync_obj._rustwright_async_close_state = _CLOSE_CLOSED - sync_obj._rustwright_async_close_task = None + write_state(_CLOSE_CLOSED, None) def _native_normalize_selector(selector: str, *, method: str) -> str: @@ -359,8 +380,35 @@ def _native_page_hot_path_supported(page: Any) -> bool: async def _native_context_page(context: SyncBrowserContext) -> SyncPage: - core = await _await_native(context._core.new_page_async()) - return await _finish_native_page(context, core) + context._begin_page_creation(method="BrowserContext.new_page") + try: + core = await _await_native(context._core.new_page_async()) + return await _finish_native_page(context, core) + finally: + context._finish_page_creation() + +async def _wait_for_page_creations(context: SyncBrowserContext) -> None: + deadline = _sync_close_wait_deadline(context) + while True: + with context._rustwright_sync_close_condition: + pending = context._rustwright_sync_close_page_creation_pending + if not pending: + return + if deadline - time.monotonic() <= 0: + _raise_close_wait_timeout("BrowserContext.close") + await asyncio.sleep(0) + + +def _update_async_cleanup_complete(context: SyncBrowserContext) -> None: + context._rustwright_async_close_cleanup_complete = all( + bool(getattr(context, name, False)) + for name in ( + "_rustwright_async_close_har_written", + "_rustwright_async_close_default_context_cleaned", + "_rustwright_async_close_pages_closed", + "_rustwright_async_close_request_disposed", + ) + ) async def _finish_native_page(context: SyncBrowserContext, core: Any) -> SyncPage: @@ -372,7 +420,7 @@ async def _finish_native_page(context: SyncBrowserContext, core: Any) -> SyncPag page.set_default_timeout(context._default_timeout) if context._default_navigation_timeout is not None: page.set_default_navigation_timeout(context._default_navigation_timeout) - context._pages.append(page) + context._register_page(page, method="BrowserContext.new_page") registered = True if context._event_handlers.get("page"): await _await_cleanup_completion(_run_sync_call(context._ensure_page_popup_bridge, page)) @@ -381,7 +429,9 @@ async def _finish_native_page(context: SyncBrowserContext, core: Any) -> SyncPag return page except BaseException: if registered and page in context._pages: - context._pages.remove(page) + with context._rustwright_sync_close_condition: + if page in context._pages: + context._pages.remove(page) popup_handler = context._popup_bridge_handlers.pop(page, None) if popup_handler is not None: page.remove_listener("popup", popup_handler) @@ -2134,17 +2184,39 @@ async def new_page( and not self._sync._browser_download_behavior and not bool(self._sync._core.single_process_fallback()) ): - core = await _await_native(self._sync._core.new_context_async(None)) - context = SyncBrowserContext(core, browser=self._sync, options={}) - self._sync._contexts.append(context) + self._sync._begin_context_creation(method="Browser.new_page") + context: Optional[SyncBrowserContext] = None + context_core: Any = None + try: + context_core = await _await_native(self._sync._core.new_context_async(None)) + context = SyncBrowserContext(context_core, browser=self._sync, options={}) + self._sync._append_context(context, method="Browser.new_page") + except BaseException: + try: + if context_core is not None: + await _await_cleanup_completion(context_core.close_async()) + except BaseException: + pass + raise + finally: + self._sync._finish_context_creation() try: - page_core = await _await_native(core.new_page_async()) + context._begin_page_creation(method="Browser.new_page") + except BaseException: + await _run_sync_call(context.close) + raise + try: + page_core = await _await_native(context_core.new_page_async()) page = await _finish_native_page(context, page_core) except BaseException: + context._finish_page_creation() await _run_sync_call(context.close) raise + else: + context._finish_page_creation() page._owns_context = True return _wrap_async_page(page) + return _wrap_async_page(await _run_sync_call(self._sync.new_page, **options)) async def new_context( @@ -2191,16 +2263,27 @@ async def new_context( if ( isinstance(self._sync, SyncBrowser) and not options - and not self._sync._closed and not self._sync._browser_download_behavior and not self._sync._launch_proxy and not self._sync._launch_downloads_path and not bool(self._sync._core.single_process_fallback()) ): - core = await _await_native(self._sync._core.new_context_async(None)) - context = SyncBrowserContext(core, browser=self._sync, options={}) - self._sync._contexts.append(context) - return _wrap_async_browser_context(context) + self._sync._begin_context_creation(method="Browser.new_context") + context_core: Any = None + try: + context_core = await _await_native(self._sync._core.new_context_async(None)) + context = SyncBrowserContext(context_core, browser=self._sync, options={}) + self._sync._append_context(context, method="Browser.new_context") + return _wrap_async_browser_context(context) + except BaseException: + try: + if context_core is not None: + await _await_cleanup_completion(context_core.close_async()) + except BaseException: + pass + raise + finally: + self._sync._finish_context_creation() return _wrap_async_browser_context(await _run_sync_call(self._sync.new_context, **options)) async def close(self, *, reason: Optional[str] = None) -> None: @@ -2326,8 +2409,6 @@ async def _close_native_impl(self, *, reason: Optional[str], for_browser_close: close = self._sync._close_for_browser_close if for_browser_close else self._sync.close await _run_sync_call(close, reason=reason) return - if self._sync._closed: - return normalized_reason = None if reason is not None: normalized_reason = _normalize_string_option( @@ -2335,20 +2416,78 @@ async def _close_native_impl(self, *, reason: Optional[str], for_browser_close: method="BrowserContext.close", name="reason", ) + close_started = self._sync._try_begin_async_close() + while close_started is None: + await asyncio.sleep(0) + close_started = self._sync._try_begin_async_close() + if not close_started: + return self._sync._closed_reason = normalized_reason - if not for_browser_close or self._sync._owns_browser: - await _run_sync_call(self._sync._cleanup_default_context_state) - for page in list(self._sync._pages): - await _wrap_async_page(page).close(reason=normalized_reason) - if self._sync._core is not None: - await _await_native(self._sync._core.close_async()) - await _run_sync_call(self._sync.request.dispose) - self._sync._closed = True - self._sync._pages.clear() - if self._sync._browser is not None and self._sync in self._sync._browser._contexts: - self._sync._browser._contexts.remove(self._sync) - _emit_event(self._sync._event_handlers, "close", self._sync) - self._sync._release_memory_buffers() + try: + await _wait_for_page_creations(self._sync) + if not self._sync._rustwright_async_close_har_written: + if self._sync._record_har_path: + await _run_sync_call( + _write_har, + self._sync._record_har_path, + list(self._sync._pages), + self._sync._record_har_url_filter, + content_mode=self._sync._record_har_content, + har_mode=self._sync._record_har_mode, + ) + self._sync._rustwright_async_close_har_written = True + self._sync._rustwright_sync_close_har_written = True + _update_async_cleanup_complete(self._sync) + _update_sync_cleanup_complete(self._sync) + if not self._sync._rustwright_async_close_default_context_cleaned: + if not for_browser_close or self._sync._owns_browser: + await _run_sync_call(self._sync._cleanup_default_context_state) + self._sync._rustwright_async_close_default_context_cleaned = True + self._sync._rustwright_sync_close_default_context_cleaned = True + _update_async_cleanup_complete(self._sync) + _update_sync_cleanup_complete(self._sync) + if not self._sync._rustwright_async_close_pages_closed: + for page in list(self._sync._pages): + try: + await _wrap_async_page(page).close(reason=normalized_reason) + except Error as exc: + if not _is_ignorable_close_error(exc): + raise + self._sync._pages.clear() + self._sync._rustwright_async_close_pages_closed = True + self._sync._rustwright_sync_close_pages_closed = True + _update_async_cleanup_complete(self._sync) + _update_sync_cleanup_complete(self._sync) + if ( + self._sync._core is not None + and not self._sync._rustwright_async_close_native_disposed + ): + try: + await _await_native(self._sync._core.close_async()) + except Error as exc: + if not _is_ignorable_close_error(exc): + raise + self._sync._rustwright_async_close_native_disposed = True + self._sync._rustwright_sync_close_native_disposed = True + elif self._sync._core is None: + self._sync._rustwright_async_close_native_disposed = True + self._sync._rustwright_sync_close_native_disposed = True + if not self._sync._rustwright_async_close_request_disposed: + await _run_sync_call(self._sync.request.dispose) + self._sync._rustwright_async_close_request_disposed = True + self._sync._rustwright_sync_close_request_disposed = True + _update_async_cleanup_complete(self._sync) + _update_sync_cleanup_complete(self._sync) + self._sync._closed = True + if self._sync._browser is not None and self._sync in self._sync._browser._contexts: + self._sync._browser._contexts.remove(self._sync) + _emit_event(self._sync._event_handlers, "close", self._sync) + self._sync._release_memory_buffers() + except BaseException as exc: + self._sync._finish_close_failure(exc) + raise + else: + self._sync._finish_close_success() def is_closed(self) -> bool: return self._sync.is_closed() @@ -2658,6 +2797,7 @@ async def goto( else _normalize_string_option(referer, method="Page.goto", name="referer") ) target = self._sync._resolve_url(target) + self._sync._mark_context_pageload_pending() self._sync._mark_request_cookie_sync_required() before, _ = await _run_sync_call(self._sync._prepare_navigation) await _run_sync_call(self._sync._mark_navigation_history_boundary, before) @@ -2680,6 +2820,7 @@ async def goto( ) ) except Error as exc: + await _run_sync_call(self._sync._clear_context_pageload_pending) message = str(exc).splitlines()[0] if ( download_waiter is not None @@ -2703,6 +2844,8 @@ async def goto( if self._sync._context is not None: await _run_sync_call(self._sync._context._apply_storage_state_to_page, self._sync) self._sync._slow_mo() + if normalized_state not in {"commit", "domcontentloaded"}: + await _run_sync_call(self._sync._dispatch_context_pageload_if_pending) return _wrap_async_response(response) async def wait_for_load_state(self, state: Optional[str] = None, *, timeout: Optional[float] = None) -> None: @@ -3264,6 +3407,12 @@ async def _close_native(self, normalized_reason: Optional[str], unload: bool) -> 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", diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 25812e4..79a949e 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -47,6 +47,9 @@ _DOWNLOADS_NOT_ACCEPTED = "Pass 'accept_downloads=True' when you are creating your browser context." _MISSING = object() _UNSET = object() +_SYNC_CLOSE_OPEN = "open" +_SYNC_CLOSE_CLOSING = "closing" +_SYNC_CLOSE_CLOSED = "closed" _HISTORY_EVENT_DRAIN_TIMEOUT_SECONDS = 0.02 _CONSOLE_HISTORY_SETTLE_TIMEOUT_SECONDS = 0.001 _CONSOLE_HISTORY_BUFFER = "__console_history__" @@ -1831,6 +1834,121 @@ def _is_ignorable_close_error(error: Error) -> bool: ) ) +@dataclass(frozen=True) +class _CloseErrorSnapshot: + """Immutable close failure data used to construct caller-local errors.""" + + error_type: Type[BaseException] + message: str + wire_kind: Optional[str] = None + wire_payload_json: Optional[str] = None + failure_kind: Optional[str] = None + failure_phase: Optional[str] = None + failure_target_kind: Optional[str] = None + failure_command_written: Optional[str] = None + failure_retryable: Optional[bool] = None + cause: Optional["_CloseErrorSnapshot"] = None + + @classmethod + def from_exception( + cls, + error: BaseException, + *, + _depth: int = 0, + ) -> "_CloseErrorSnapshot": + wire_kind = getattr(error, _WIRE_ERROR_KIND_ATTRIBUTE, None) + wire_payload = getattr(error, _WIRE_ERROR_PAYLOAD_ATTRIBUTE, None) + try: + wire_payload_json = ( + json.dumps(wire_payload, sort_keys=True, separators=(",", ":")) + if isinstance(wire_kind, str) and isinstance(wire_payload, dict) + else None + ) + except (TypeError, ValueError): + wire_payload_json = None + cause = None + if _depth < 4 and isinstance(error.__cause__, BaseException) and error.__cause__ is not error: + cause = cls.from_exception(error.__cause__, _depth=_depth + 1) + return cls( + error_type=type(error), + message=str(error), + wire_kind=wire_kind if isinstance(wire_kind, str) else None, + wire_payload_json=wire_payload_json, + failure_kind=getattr(error, "_rustwright_failure_kind", None), + failure_phase=getattr(error, "_rustwright_failure_phase", None), + failure_target_kind=getattr(error, "_rustwright_failure_target_kind", None), + failure_command_written=getattr(error, "_rustwright_failure_command_written", None), + failure_retryable=getattr(error, "_rustwright_failure_retryable", None), + cause=cause, + ) + + def instantiate(self) -> BaseException: + try: + error = self.error_type(self.message) + except Exception: + error = Error(self.message) + if isinstance(error, Error): + if self.wire_kind is not None and self.wire_payload_json is not None: + try: + _annotate_wire_error(error, self.wire_kind, json.loads(self.wire_payload_json)) + except (TypeError, ValueError): + pass + for name, value in ( + ("_rustwright_failure_kind", self.failure_kind), + ("_rustwright_failure_phase", self.failure_phase), + ("_rustwright_failure_target_kind", self.failure_target_kind), + ("_rustwright_failure_command_written", self.failure_command_written), + ("_rustwright_failure_retryable", self.failure_retryable), + ): + if value is not None: + setattr(error, name, value) + return error + + +@dataclass(frozen=True) +class _CloseAttemptOutcome: + generation: int + error: Optional[_CloseErrorSnapshot] = None + + +def _raise_close_error(snapshot: _CloseErrorSnapshot) -> None: + error = snapshot.instantiate() + cause = snapshot.cause.instantiate() if snapshot.cause is not None else snapshot.instantiate() + raise error from cause + + +def _is_context_cleanup_complete(context: Any) -> bool: + return all( + bool(getattr(context, name, False)) + for name in ( + "_rustwright_sync_close_har_written", + "_rustwright_sync_close_default_context_cleaned", + "_rustwright_sync_close_pages_closed", + "_rustwright_sync_close_request_disposed", + ) + ) + + +def _update_sync_cleanup_complete(context: Any) -> None: + context._rustwright_sync_close_cleanup_complete = _is_context_cleanup_complete(context) + + +_CLOSE_WAIT_FALLBACK_MS = 30_000.0 + + +def _sync_close_wait_deadline(owner: Any) -> float: + try: + timeout_ms = float(getattr(owner, "_default_timeout", _CLOSE_WAIT_FALLBACK_MS)) + except (TypeError, ValueError): + timeout_ms = _CLOSE_WAIT_FALLBACK_MS + if not math.isfinite(timeout_ms) or timeout_ms <= 0: + timeout_ms = _CLOSE_WAIT_FALLBACK_MS + return time.monotonic() + max(timeout_ms, 1.0) / 1000 + + +def _raise_close_wait_timeout(method: str) -> None: + raise TimeoutError(f"{method}: timed out waiting for concurrent close to finish") + def _clean_options(options: Dict[str, Any]) -> Dict[str, Any]: return {key: value for key, value in options.items() if value is not None} @@ -11673,6 +11791,15 @@ def __init__(self, core: Any, *, launch_options: Optional[Dict[str, Any]] = None self._connected_over_cdp = bool(launch_options.get("_connected_over_cdp")) self._owned_cdp_sessions: list[CDPSession] = [] 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_error: Optional[_CloseErrorSnapshot] = 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_context_creation_pending = 0 + self._rustwright_sync_close_context_creation_threads: dict[int, int] = {} self._rustwright_async_close_state = "open" self._rustwright_async_close_task: Any = None self._closed_reason: Optional[str] = None @@ -11812,7 +11939,124 @@ def new_context( options = _options_from_explicit_kwargs(locals()) return self._new_context_from_options(options) + def _assert_open_for_context_creation(self, *, method: str) -> None: + with self._rustwright_sync_close_condition: + if ( + self._closed + or self._rustwright_sync_close_state != _SYNC_CLOSE_OPEN + or self._rustwright_async_close_state == "closing" + ): + raise Error(f"{method}: Browser is closed") + + def _begin_context_creation(self, *, method: str) -> None: + current_thread = threading.get_ident() + with self._rustwright_sync_close_condition: + if ( + self._closed + or self._rustwright_sync_close_state != _SYNC_CLOSE_OPEN + or self._rustwright_async_close_state == "closing" + ): + raise Error(f"{method}: Browser is closed") + self._rustwright_sync_close_context_creation_pending += 1 + self._rustwright_sync_close_context_creation_threads[current_thread] = ( + self._rustwright_sync_close_context_creation_threads.get(current_thread, 0) + 1 + ) + + def _finish_context_creation(self) -> None: + current_thread = threading.get_ident() + with self._rustwright_sync_close_condition: + self._rustwright_sync_close_context_creation_pending = max( + 0, + self._rustwright_sync_close_context_creation_pending - 1, + ) + count = self._rustwright_sync_close_context_creation_threads.get(current_thread, 0) + if count <= 1: + self._rustwright_sync_close_context_creation_threads.pop(current_thread, None) + else: + self._rustwright_sync_close_context_creation_threads[current_thread] = count - 1 + self._rustwright_sync_close_condition.notify_all() + + def _append_context(self, context: "BrowserContext", *, method: str) -> None: + with self._rustwright_sync_close_condition: + if ( + self._closed + or self._rustwright_sync_close_state != _SYNC_CLOSE_OPEN + or self._rustwright_async_close_state == "closing" + ): + raise Error(f"{method}: Browser is closed") + self._contexts.append(context) + + def _wait_for_context_creations(self) -> None: + deadline = _sync_close_wait_deadline(self) + with self._rustwright_sync_close_condition: + while self._rustwright_sync_close_context_creation_pending: + remaining = deadline - time.monotonic() + if remaining <= 0: + _raise_close_wait_timeout("Browser.close") + self._rustwright_sync_close_condition.wait(timeout=remaining) + + def _begin_browser_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: + if self._rustwright_sync_close_owner == current_thread: + return False + 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("Browser.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 + if self._closed or self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + return False + if self._rustwright_sync_close_context_creation_threads.get(current_thread, 0): + 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 + self._rustwright_sync_close_error = None + return True + + def _finish_browser_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_error = None + self._rustwright_sync_close_condition.notify_all() + + def _finish_browser_close_failure(self, error: BaseException) -> None: + with self._rustwright_sync_close_condition: + snapshot = _CloseErrorSnapshot.from_exception(error) + generation = self._rustwright_sync_close_generation + self._rustwright_sync_close_outcomes[generation] = _CloseAttemptOutcome(generation, snapshot) + self._closed = False + self._rustwright_sync_close_state = _SYNC_CLOSE_OPEN + self._rustwright_sync_close_owner = None + self._rustwright_sync_close_error = snapshot + self._rustwright_sync_close_condition.notify_all() + def _new_context_from_options(self, options: Dict[str, Any], *, method: str = "Browser.new_context") -> "BrowserContext": + self._assert_open_for_context_creation(method=method) effective_options = dict(options) _normalize_page_emulation_options(effective_options, method="Browser.new_context") _normalize_context_environment_options(effective_options, method="Browser.new_context") @@ -11839,28 +12083,60 @@ def _new_context_from_options(self, options: Dict[str, Any], *, method: str = "B effective_options["_proxy_from_launch"] = True if "downloads_path" not in effective_options and "downloadsPath" not in effective_options and self._launch_downloads_path is not None: effective_options["downloads_path"] = self._launch_downloads_path - if bool(getattr(self._core, "single_process_fallback", lambda: False)()): - context = BrowserContext(None, browser=self, options=effective_options) - self._contexts.append(context) + self._begin_context_creation(method=method) + context: Optional[BrowserContext] = None + context_core: Any = None + registered = False + try: + if bool(getattr(self._core, "single_process_fallback", lambda: False)()): + context = BrowserContext(None, browser=self, options=effective_options) + self._append_context(context, method=method) + registered = True + return context + core_options = _browser_context_core_options(effective_options, method=method) + context_core = _call( + self._core.new_context, + json_module_dumps(core_options) if core_options else None, + ) + context = BrowserContext(context_core, browser=self, options=effective_options) + self._append_context(context, method=method) + registered = True + self._apply_browser_download_behavior_to_context(context) return context - core_options = _browser_context_core_options(effective_options, method=method) - context_core = _call( - self._core.new_context, - json_module_dumps(core_options) if core_options else None, - ) - context = BrowserContext(context_core, browser=self, options=effective_options) - self._contexts.append(context) - self._apply_browser_download_behavior_to_context(context) - return context + except BaseException: + if registered and context is not None: + with self._rustwright_sync_close_condition: + if context in self._contexts: + self._contexts.remove(context) + try: + if context_core is not None: + _call(context_core.close) + except BaseException: + pass + raise + finally: + self._finish_context_creation() def close(self, *, reason: Optional[str] = None) -> None: - if self._closed: - return + with self._rustwright_sync_close_condition: + if self._closed or self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + return normalized_reason = None if reason is not None: normalized_reason = _normalize_string_option(reason, method="Browser.close", name="reason") self._closed_reason = normalized_reason - self._closed = True + if not self._begin_browser_close(): + return + try: + self._close_impl(normalized_reason=normalized_reason) + except BaseException as exc: + self._finish_browser_close_failure(exc) + raise + else: + self._finish_browser_close_success() + + def _close_impl(self, *, normalized_reason: Optional[str]) -> None: + self._wait_for_context_creations() self._mark_owned_cdp_sessions_closed() if self._connected_over_cdp: self._stop_page_event_pumps() @@ -11869,6 +12145,7 @@ def close(self, *, reason: Optional[str] = None) -> None: except Error as exc: if not _is_ignorable_close_error(exc): raise + self._closed = True self._contexts.clear() self._emit_disconnected() return @@ -11883,6 +12160,7 @@ def close(self, *, reason: Optional[str] = None) -> None: except Error as exc: if not _is_ignorable_close_error(exc): raise + self._closed = True self._contexts.clear() self._emit_disconnected() @@ -13041,8 +13319,29 @@ def __init__( self._default_timeout = 30_000.0 self._default_navigation_timeout: Optional[float] = None self._closed = False + self._rustwright_sync_close_state = _SYNC_CLOSE_OPEN + self._rustwright_sync_close_condition = threading.Condition() + self._rustwright_sync_close_error: Optional[_CloseErrorSnapshot] = None + 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_page_creation_pending = 0 + self._rustwright_sync_close_page_creation_threads: dict[int, int] = {} + self._rustwright_sync_close_har_written = not bool(self._record_har_path) + self._rustwright_sync_close_default_context_cleaned = False + self._rustwright_sync_close_pages_closed = False + self._rustwright_sync_close_request_disposed = False + self._rustwright_sync_close_cleanup_complete = False + self._rustwright_sync_close_native_disposed = False self._rustwright_async_close_state = "open" self._rustwright_async_close_task: Any = None + self._rustwright_async_close_har_written = not bool(self._record_har_path) + self._rustwright_async_close_default_context_cleaned = False + self._rustwright_async_close_pages_closed = False + self._rustwright_async_close_cleanup_complete = False + self._rustwright_async_close_native_disposed = False + self._rustwright_async_close_request_disposed = False self._closed_reason: Optional[str] = None self._clock = Clock(context=self) self._debugger = Debugger(self) @@ -13168,6 +13467,7 @@ def service_workers(self) -> list[Any]: self._persistent_browser_context_id, self._default_timeout, ) + elif self._browser is not None: cores = _call(self._browser._core.list_service_workers, self._default_timeout) else: @@ -13176,6 +13476,17 @@ def service_workers(self) -> list[Any]: self._service_worker_from_core(core) return list(self._service_workers.values()) + def _browser_is_closing(self) -> bool: + browser = self._browser + return bool( + browser is not None + and ( + bool(getattr(browser, "_closed", False)) + or getattr(browser, "_rustwright_sync_close_state", _SYNC_CLOSE_OPEN) != _SYNC_CLOSE_OPEN + or getattr(browser, "_rustwright_async_close_state", "open") == "closing" + ) + ) + @property def tracing(self) -> Tracing: return self._tracing @@ -13191,63 +13502,161 @@ def debugger(self) -> Debugger: def new_page(self) -> "Page": return self._new_page(method="BrowserContext.new_page") - def _new_page(self, *, method: str) -> "Page": - if self._closed: - raise Error("BrowserContext is closed") - if self._core is None: - if self._browser is None: - raise Error("persistent context is not attached to a browser") - core = _call(self._browser._core.new_page) - self._remember_persistent_context_id_from_core(core) - page = Page(core, context=self) - page._apply_options(self._options, method=method) - else: - page = Page(_call(self._core.new_page), context=self) - page._apply_options(self._options, method=method) - if self._core is None and self._browser is not None: - self._install_default_context_proxy_route(page) - self._install_client_certificate_route(page) - page.set_default_timeout(self._default_timeout) - if self._default_navigation_timeout is not None: - page.set_default_navigation_timeout(self._default_navigation_timeout) - for source in self._init_scripts: - page._add_init_script_source(source, run_immediately=True) - for registration in self._routes: - page._add_route_registration(registration) - for registration in self._har_routes: - page._add_route_registration(registration) - for matcher, handler in self._websocket_routes: - page.route_web_socket(matcher, handler) - for name, callback, needs_source, wants_handle in self._bindings: - page._install_context_binding( - name, - callback, - needs_source=needs_source, - wants_handle=wants_handle, + + def _begin_page_creation(self, *, method: str) -> None: + current_thread = threading.get_ident() + browser_condition = getattr(self._browser, "_rustwright_sync_close_condition", None) + if browser_condition is not None: + browser_condition.acquire() + try: + with self._rustwright_sync_close_condition: + if ( + self._closed + or self._rustwright_sync_close_state != _SYNC_CLOSE_OPEN + or self._rustwright_async_close_state == "closing" + or self._browser_is_closing() + ): + raise Error("BrowserContext is closed") + self._rustwright_sync_close_page_creation_pending += 1 + self._rustwright_sync_close_page_creation_threads[current_thread] = ( + self._rustwright_sync_close_page_creation_threads.get(current_thread, 0) + 1 + ) + finally: + if browser_condition is not None: + browser_condition.release() + + def _finish_page_creation(self) -> None: + current_thread = threading.get_ident() + with self._rustwright_sync_close_condition: + self._rustwright_sync_close_page_creation_pending = max( + 0, + self._rustwright_sync_close_page_creation_pending - 1, ) - for event in ("request", "response", "requestfinished", "requestfailed", "console", "dialog"): - for handler in self._event_handlers.get(event, []): - page.on(event, handler) - if self._event_handlers.get("weberror"): - self._ensure_page_web_error_bridge(page) - if self._event_handlers.get("pageload"): - self._ensure_page_load_bridge(page) - if self._event_handlers.get("pageclose"): - self._ensure_page_close_bridge(page) - if self._record_har_path: - self._enable_har_recording_for_page(page) - if self._record_video_dir: - page._start_video_recording(self._record_video_dir, size=self._record_video_size) - if self._tracing._started: - self._tracing._attach_page(page) - self._pages.append(page) - if self._clock._installed: - self._clock._ensure_page(page) - if self._event_handlers.get("page"): - self._ensure_page_popup_bridge(page) - _emit_event(self._event_handlers, "page", page) - page._core.mark_delivered() - return page + count = self._rustwright_sync_close_page_creation_threads.get(current_thread, 0) + if count <= 1: + self._rustwright_sync_close_page_creation_threads.pop(current_thread, None) + else: + self._rustwright_sync_close_page_creation_threads[current_thread] = count - 1 + self._rustwright_sync_close_condition.notify_all() + + def _register_page(self, page: "Page", *, method: str) -> None: + browser_condition = getattr(self._browser, "_rustwright_sync_close_condition", None) + if browser_condition is not None: + browser_condition.acquire() + try: + with self._rustwright_sync_close_condition: + if ( + self._closed + or self._rustwright_sync_close_state != _SYNC_CLOSE_OPEN + or self._rustwright_async_close_state == "closing" + or self._browser_is_closing() + ): + raise Error("BrowserContext is closed") + self._pages.append(page) + finally: + if browser_condition is not None: + browser_condition.release() + + def _wait_for_page_creations(self) -> None: + deadline = _sync_close_wait_deadline(self) + with self._rustwright_sync_close_condition: + while self._rustwright_sync_close_page_creation_pending: + remaining = deadline - time.monotonic() + if remaining <= 0: + _raise_close_wait_timeout("BrowserContext.close") + self._rustwright_sync_close_condition.wait(timeout=remaining) + + def _try_begin_async_close(self) -> Optional[bool]: + current_thread = threading.get_ident() + with self._rustwright_sync_close_condition: + if self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSING: + if self._rustwright_sync_close_owner == current_thread: + return False + return None + if self._closed or self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + 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 + self._rustwright_sync_close_error = None + return True + + def _new_page(self, *, method: str) -> "Page": + self._begin_page_creation(method=method) + page: Optional[Page] = None + registered = False + try: + if self._core is None: + if self._browser is None: + raise Error("persistent context is not attached to a browser") + core = _call(self._browser._core.new_page) + self._remember_persistent_context_id_from_core(core) + page = Page(core, context=self) + page._apply_options(self._options, method=method) + else: + page = Page(_call(self._core.new_page), context=self) + page._apply_options(self._options, method=method) + if self._core is None and self._browser is not None: + self._install_default_context_proxy_route(page) + self._install_client_certificate_route(page) + page.set_default_timeout(self._default_timeout) + if self._default_navigation_timeout is not None: + page.set_default_navigation_timeout(self._default_navigation_timeout) + for source in self._init_scripts: + page._add_init_script_source(source, run_immediately=True) + for registration in self._routes: + page._add_route_registration(registration) + for registration in self._har_routes: + page._add_route_registration(registration) + for matcher, handler in self._websocket_routes: + page.route_web_socket(matcher, handler) + for name, callback, needs_source, wants_handle in self._bindings: + page._install_context_binding( + name, + callback, + needs_source=needs_source, + wants_handle=wants_handle, + ) + for event in ("request", "response", "requestfinished", "requestfailed", "console", "dialog"): + for handler in self._event_handlers.get(event, []): + page.on(event, handler) + if self._event_handlers.get("weberror"): + self._ensure_page_web_error_bridge(page) + if self._event_handlers.get("pageload"): + self._ensure_page_load_bridge(page) + if self._event_handlers.get("pageclose"): + self._ensure_page_close_bridge(page) + if self._record_har_path: + self._enable_har_recording_for_page(page) + if self._record_video_dir: + page._start_video_recording(self._record_video_dir, size=self._record_video_size) + if self._tracing._started: + self._tracing._attach_page(page) + self._register_page(page, method=method) + registered = True + if self._clock._installed: + self._clock._ensure_page(page) + if self._event_handlers.get("page"): + self._ensure_page_popup_bridge(page) + _emit_event(self._event_handlers, "page", page) + page._core.mark_delivered() + return page + except BaseException: + if page is not None: + if registered: + with self._rustwright_sync_close_condition: + if page in self._pages: + self._pages.remove(page) + try: + page.close() + except BaseException: + pass + raise + finally: + self._finish_page_creation() def _install_default_context_proxy_route(self, page: "Page") -> None: proxy = self._options.get("proxy") @@ -13341,7 +13750,14 @@ def _adopt_popup(self, page: "Page") -> "Page": page._start_video_recording(self._record_video_dir, size=self._record_video_size) if self._tracing._started: self._tracing._attach_page(page) - self._pages.append(page) + try: + self._register_page(page, method="BrowserContext._adopt_popup") + except Error: + try: + page.close() + except BaseException: + pass + return page if self._clock._installed: self._clock._ensure_page(page) self._ensure_page_popup_bridge(page) @@ -13373,7 +13789,7 @@ def _ensure_page_load_bridge(self, page: "Page") -> None: return def page_load_handler(loaded_page: "Page") -> None: - _emit_event(self._event_handlers, "pageload", loaded_page) + loaded_page._dispatch_context_pageload_if_pending() self._page_load_bridge_handlers[page] = page_load_handler page.on("load", page_load_handler) @@ -13419,44 +13835,145 @@ def _release_memory_buffers(self) -> None: self._bindings.clear() self._storage_state_origins.clear() - def close(self, *, reason: Optional[str] = None) -> None: - if self._closed: - return - normalized_reason = None - if reason is not None: - normalized_reason = _normalize_string_option(reason, method="BrowserContext.close", name="reason") - self._closed_reason = normalized_reason - if self._record_har_path: - _write_har( - self._record_har_path, - list(self._pages), - self._record_har_url_filter, - content_mode=self._record_har_content, - har_mode=self._record_har_mode, + def _begin_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: + if self._rustwright_sync_close_owner == current_thread: + return False + 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("BrowserContext.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 + if self._closed or self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + return False + if self._rustwright_sync_close_page_creation_threads.get(current_thread, 0): + 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 + self._rustwright_sync_close_error = None + return True + + def _finish_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_error = None + self._rustwright_sync_close_condition.notify_all() + + def _finish_close_failure(self, error: BaseException) -> None: + with self._rustwright_sync_close_condition: + snapshot = _CloseErrorSnapshot.from_exception(error) + generation = self._rustwright_sync_close_generation + self._rustwright_sync_close_outcomes[generation] = _CloseAttemptOutcome(generation, snapshot) + terminal = ( + self._closed + and self._rustwright_sync_close_native_disposed + and _is_context_cleanup_complete(self) ) - self._cleanup_default_context_state() - self._closed = True - for page in list(self._pages): - try: - page.close(reason=normalized_reason) - except Error as exc: - if not _is_ignorable_close_error(exc): - raise - self._pages.clear() - self.request.dispose() - if self._core is not None: + if terminal: + self._rustwright_sync_close_state = _SYNC_CLOSE_CLOSED + else: + self._closed = False + self._rustwright_sync_close_state = _SYNC_CLOSE_OPEN + self._rustwright_sync_close_error = snapshot + self._rustwright_sync_close_owner = None + self._rustwright_sync_close_condition.notify_all() + + def _close_impl(self, *, normalized_reason: Optional[str], for_browser_close: bool) -> None: + self._wait_for_page_creations() + if not self._rustwright_sync_close_har_written: + if self._record_har_path: + _write_har( + self._record_har_path, + list(self._pages), + self._record_har_url_filter, + content_mode=self._record_har_content, + har_mode=self._record_har_mode, + ) + self._rustwright_sync_close_har_written = True + _update_sync_cleanup_complete(self) + if not self._rustwright_sync_close_default_context_cleaned: + if not for_browser_close or self._owns_browser: + self._cleanup_default_context_state() + self._rustwright_sync_close_default_context_cleaned = True + _update_sync_cleanup_complete(self) + if not for_browser_close: + self._closed = True + if not self._rustwright_sync_close_pages_closed: + for page in list(self._pages): + try: + page.close(reason=normalized_reason) + except Error as exc: + if not _is_ignorable_close_error(exc): + raise + self._pages.clear() + self._rustwright_sync_close_pages_closed = True + _update_sync_cleanup_complete(self) + if not self._rustwright_sync_close_request_disposed: + self.request.dispose() + self._rustwright_sync_close_request_disposed = True + _update_sync_cleanup_complete(self) + + if self._core is None: + self._rustwright_sync_close_native_disposed = True + elif not self._rustwright_sync_close_native_disposed: try: _call(self._core.close) except Error as exc: if not _is_ignorable_close_error(exc): raise - if self._owns_browser and self._browser is not None: + self._rustwright_sync_close_native_disposed = True + + self._closed = True + if self._owns_browser and not for_browser_close and self._browser is not None: self._browser.close() if self._browser is not None and self in self._browser._contexts: self._browser._contexts.remove(self) _emit_event(self._event_handlers, "close", self) self._release_memory_buffers() + def close(self, *, reason: Optional[str] = None) -> None: + 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="BrowserContext.close", name="reason") + self._closed_reason = normalized_reason + if not self._begin_close(): + return + try: + self._close_impl(normalized_reason=normalized_reason, for_browser_close=False) + except BaseException as exc: + self._finish_close_failure(exc) + raise + else: + self._finish_close_success() + def _cleanup_default_context_state(self) -> None: if self._core is not None or self._browser is None: return @@ -13496,38 +14013,18 @@ def _cleanup_default_context_state(self) -> None: pass def _close_for_browser_close(self, *, reason: Optional[str] = None) -> None: - if self._closed: - return - if self._record_har_path: - _write_har( - self._record_har_path, - list(self._pages), - self._record_har_url_filter, - content_mode=self._record_har_content, - har_mode=self._record_har_mode, - ) - if self._owns_browser: - self._cleanup_default_context_state() - for page in list(self._pages): - try: - page.close(reason=reason) - except Error as exc: - if not _is_ignorable_close_error(exc): - raise - if self._closed: + with self._rustwright_sync_close_condition: + if self._rustwright_sync_close_state == _SYNC_CLOSE_CLOSED: + return + if not self._begin_close(): return - self._closed = True - self._pages.clear() - self.request.dispose() - if self._core is not None: - try: - _call(self._core.close) - except Error as exc: - if not _is_ignorable_close_error(exc): - raise - if self._browser is not None and self in self._browser._contexts: - self._browser._contexts.remove(self) - _emit_event(self._event_handlers, "close", self) + try: + self._close_impl(normalized_reason=reason, for_browser_close=True) + except BaseException as exc: + self._finish_close_failure(exc) + raise + else: + self._finish_close_success() def _enable_har_recording_for_page(self, page: "Page") -> None: page.on("request", lambda _: None) @@ -15758,6 +16255,8 @@ def __init__( self._request = context.request if context is not None else APIRequestContext() self._event_handlers: dict[str, list[Callable[..., Any]]] = {} self._event_handler_cursors: dict[tuple[str, int], Optional[int]] = {} + self._context_pageload_lock = threading.Lock() + self._context_pageload_pending = False self._main_frame = Frame(self, name="", url="", is_main=True) self._set_content_html_document_known: Optional[bool] = True self._frame_object_cache: dict[str, Frame] = {} @@ -16802,6 +17301,7 @@ def goto( if referer is not None: normalized_referer = _normalize_string_option(referer, method="Page.goto", name="referer") target_url = self._resolve_url(url) + self._mark_context_pageload_pending() self._mark_request_cookie_sync_required() self._set_content_html_document_known = None call_id = self._trace_begin_action( @@ -16870,6 +17370,7 @@ def goto( response_log = list(self._response_log) for response in reversed(response_log): if response.url == target_url and int(response.status or 0) >= 400: + self._clear_context_pageload_pending() self._trace_end_action(call_id, result={"response": {"url": response.url, "status": response.status}}) return self._remember_navigation_response(response) time.sleep(0.02) @@ -16882,7 +17383,7 @@ def goto( request=request, ) request._response = response - response._page = self + self._clear_context_pageload_pending() self._trace_end_action(call_id, result={"response": {"url": response.url, "status": response.status}}) return self._remember_navigation_response(response) raise @@ -16896,6 +17397,8 @@ def goto( if self._context is not None: self._context._apply_storage_state_to_page(self) self._slow_mo() + if normalized_state not in {"commit", "domcontentloaded"}: + self._dispatch_context_pageload_if_pending() self._trace_end_action(call_id, result={"response": None}) return None response = _response_from_payload(self, payload, fallback_url=target_url) @@ -16903,6 +17406,7 @@ def goto( self._context._apply_storage_state_to_page(self) self._slow_mo() except Exception as exc: + self._clear_context_pageload_pending() if single_process_crash_navigation: crash_error = Error("Page crashed") self._mark_crashed() @@ -16918,6 +17422,8 @@ def goto( else {"url": response.url, "status": response.status}, }, ) + if normalized_state not in {"commit", "domcontentloaded"}: + self._dispatch_context_pageload_if_pending() return self._remember_navigation_response(response) def reload( @@ -16933,11 +17439,22 @@ def reload( prior_time_origin = self.evaluate("() => performance.timeOrigin") except Error: prior_time_origin = None - before, _ = self._prepare_navigation() - self._mark_navigation_history_boundary(before) - self._set_content_html_document_known = None - waiter = self._core.network_event_waiter("response") - payload = json.loads(_call_wait_with_playwright_timeout("Page.reload", self._core.reload, "commit", reload_timeout)) + self._mark_context_pageload_pending() + try: + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) + self._set_content_html_document_known = None + waiter = self._core.network_event_waiter("response") + except BaseException: + self._clear_context_pageload_pending() + raise + try: + payload = json.loads( + _call_wait_with_playwright_timeout("Page.reload", self._core.reload, "commit", reload_timeout) + ) + except BaseException: + self._clear_context_pageload_pending() + raise event = None if payload is not None: event = _response_from_payload(self, payload, fallback_url=current_url) @@ -16955,8 +17472,11 @@ def reload( try: self._wait_for_reload_document_state(normalized_state, prior_time_origin, reload_timeout) except TimeoutError: + self._clear_context_pageload_pending() raise _method_timeout_error("Page.reload", reload_timeout) from None self._slow_mo() + if normalized_state not in {"commit", "domcontentloaded"}: + self._dispatch_context_pageload_if_pending() if isinstance(event, Response): return self._remember_navigation_response(event) if payload is None: @@ -17064,29 +17584,41 @@ def go_back( ) -> Optional[Response]: navigation_timeout = _navigation_timeout_for_method(self, timeout, method="Page.go_back") normalized_state = _normalize_lifecycle_state(wait_until, label="wait_until", method="Page.go_back") - self._set_content_html_document_known = None - boundary = self._navigation_history_boundary() - before, _ = self._prepare_navigation() - self._mark_navigation_history_boundary(before) - before_url = self.url + self._mark_context_pageload_pending() try: - payload = json.loads(_call_wait_with_playwright_timeout( - "Page.go_back", - self._core.go_back, - normalized_state, - navigation_timeout, - )) + self._set_content_html_document_known = None + boundary = self._navigation_history_boundary() + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) + before_url = self.url + except BaseException: + self._clear_context_pageload_pending() + raise + try: + payload = json.loads( + _call_wait_with_playwright_timeout( + "Page.go_back", + self._core.go_back, + normalized_state, + navigation_timeout, + ) + ) except Exception: + self._clear_context_pageload_pending() self._restore_navigation_history_boundary(boundary) raise if payload is None: cached_response = None if self.url == before_url else self._cached_navigation_response_for_current_url() if cached_response is None: + self._clear_context_pageload_pending() self._restore_navigation_history_boundary(boundary) return None - return cached_response - response = _response_from_payload(self, payload, fallback_url=self.url) + response = cached_response + else: + response = _response_from_payload(self, payload, fallback_url=self.url) self._slow_mo() + if normalized_state not in {"commit", "domcontentloaded"}: + self._dispatch_context_pageload_if_pending() return self._remember_navigation_response(response) def go_forward( @@ -17097,29 +17629,41 @@ def go_forward( ) -> Optional[Response]: navigation_timeout = _navigation_timeout_for_method(self, timeout, method="Page.go_forward") normalized_state = _normalize_lifecycle_state(wait_until, label="wait_until", method="Page.go_forward") - boundary = self._navigation_history_boundary() - before, _ = self._prepare_navigation() - self._mark_navigation_history_boundary(before) - self._set_content_html_document_known = None - before_url = self.url + self._mark_context_pageload_pending() try: - payload = json.loads(_call_wait_with_playwright_timeout( - "Page.go_forward", - self._core.go_forward, - normalized_state, - navigation_timeout, - )) + boundary = self._navigation_history_boundary() + before, _ = self._prepare_navigation() + self._mark_navigation_history_boundary(before) + self._set_content_html_document_known = None + before_url = self.url + except BaseException: + self._clear_context_pageload_pending() + raise + try: + payload = json.loads( + _call_wait_with_playwright_timeout( + "Page.go_forward", + self._core.go_forward, + normalized_state, + navigation_timeout, + ) + ) except Exception: + self._clear_context_pageload_pending() self._restore_navigation_history_boundary(boundary) raise if payload is None: cached_response = None if self.url == before_url else self._cached_navigation_response_for_current_url() if cached_response is None: + self._clear_context_pageload_pending() self._restore_navigation_history_boundary(boundary) return None - return cached_response - response = _response_from_payload(self, payload, fallback_url=self.url) + response = cached_response + else: + response = _response_from_payload(self, payload, fallback_url=self.url) self._slow_mo() + if normalized_state not in {"commit", "domcontentloaded"}: + self._dispatch_context_pageload_if_pending() return self._remember_navigation_response(response) def wait_for_url( @@ -20738,7 +21282,12 @@ def close(self, *, run_before_unload: Optional[bool] = None, reason: Optional[st 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: + 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() def get_by_text(self, text: str, *, exact: bool = False) -> "Locator": @@ -21368,6 +21917,25 @@ def _wait_for_page_cdp_event_dispatch( return self._page_cdp_event_condition.wait(timeout=remaining) + def _mark_context_pageload_pending(self) -> None: + with self._context_pageload_lock: + self._context_pageload_pending = True + + def _clear_context_pageload_pending(self) -> None: + with self._context_pageload_lock: + self._context_pageload_pending = False + + def _dispatch_context_pageload_if_pending(self) -> None: + with self._context_pageload_lock: + if not self._context_pageload_pending: + return + self._context_pageload_pending = False + if self._context is not None: + try: + _emit_event(self._context._event_handlers, "pageload", self) + except Exception: + pass + def _handle_page_cdp_event(self, event: str) -> None: self._dispatch_event_handlers(event, self) with self._page_cdp_event_condition: From a498e7fd6cdf8d983b7a5976550018964056a086 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:44:21 +0000 Subject: [PATCH 2/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/237 --- tests/test_rustwright_sync_api.py | 808 ++++++++++++++++++++++++++++++ 1 file changed, 808 insertions(+) diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 024a8ab..13159ef 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -4597,6 +4597,814 @@ def test_context_close_closes_pages_before_context_close_event(playwright): browser.close() +def test_new_context_download_behavior_failure_rolls_back_sync_context(): + from rustwright.sync_api import Browser + + class ContextCore: + context_id = "download-behavior-sync" + + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + raise RuntimeError("context disposal failed") + + class BrowserCore: + def __init__(self, context_core): + self.context_core = context_core + + def single_process_fallback(self): + return False + + def new_context(self, _options): + return self.context_core + + context_core = ContextCore() + browser = Browser(BrowserCore(context_core)) + browser._browser_download_behavior = {"behavior": "allow"} + original = RuntimeError("download behavior failed") + + def fail_download_behavior(_context): + raise original + + browser._apply_browser_download_behavior_to_context = fail_download_behavior + + with pytest.raises(RuntimeError) as exc_info: + browser.new_context() + + assert exc_info.value is original + assert browser.contexts == [] + assert context_core.close_calls == 1 + + +def test_new_context_download_behavior_failure_rolls_back_async_slow_path(): + from playwright.async_api import Browser + + class ContextCore: + context_id = "download-behavior-async" + + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + raise RuntimeError("context disposal failed") + + class BrowserCore: + def __init__(self, context_core): + self.context_core = context_core + + def single_process_fallback(self): + return False + + def new_context(self, _options): + return self.context_core + + async def run(): + context_core = ContextCore() + sync_browser = rustwright.sync_api.Browser(BrowserCore(context_core)) + sync_browser._browser_download_behavior = {"behavior": "allow"} + original = RuntimeError("download behavior failed") + + def fail_download_behavior(_context): + raise original + + sync_browser._apply_browser_download_behavior_to_context = fail_download_behavior + browser = Browser(sync_browser) + + with pytest.raises(RuntimeError) as exc_info: + await browser.new_context() + + assert exc_info.value is original + assert sync_browser.contexts == [] + assert context_core.close_calls == 1 + + asyncio.run(run()) + + +def test_context_close_retries_native_disposal_without_repeating_cleanup(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + raise RuntimeError("transient context disposal failure") + + class Page: + def __init__(self): + self.close_calls = 0 + + def close(self, **_kwargs): + self.close_calls += 1 + + core = ContextCore() + page = Page() + context = BrowserContext(core) + context._pages.append(page) + request_dispose_calls = [] + context.request.dispose = lambda: request_dispose_calls.append("dispose") + + with pytest.raises(Error, match="transient context disposal failure"): + context.close() + + assert not context.is_closed() + assert core.close_calls == 1 + assert page.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + context.close() + context.close() + + assert context.is_closed() + assert core.close_calls == 2 + assert page.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + +def test_context_close_concurrent_calls_dispose_native_context_once(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + self.close_started = threading.Event() + self.release_close = threading.Event() + + def close(self): + self.close_calls += 1 + self.close_started.set() + assert self.release_close.wait() + + class Page: + def __init__(self): + self.close_calls = 0 + + def close(self, **_kwargs): + self.close_calls += 1 + + core = ContextCore() + page = Page() + context = BrowserContext(core) + context._pages.append(page) + request_dispose_calls = [] + context.request.dispose = lambda: request_dispose_calls.append("dispose") + errors = [] + second_waiting = threading.Event() + original_condition_wait = context._rustwright_sync_close_condition.wait + + def condition_wait(timeout=None): + second_waiting.set() + return original_condition_wait(timeout) + + context._rustwright_sync_close_condition.wait = condition_wait + + def close_context(): + try: + context.close() + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=close_context) + second = threading.Thread(target=close_context) + first.start() + assert core.close_started.wait(5) + second.start() + assert second_waiting.wait(5) + core.release_close.set() + first.join() + second.join() + + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + assert context.is_closed() + assert core.close_calls == 1 + assert page.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + +def test_owned_page_close_does_not_reenter_closing_context(): + from rustwright.sync_api import BrowserContext, Page + + class EventStream: + def close(self): + pass + + class PageCore: + def __init__(self): + self.close_calls = 0 + + def keyboard_primary_modifier(self): + return "Control" + + def combined_event_stream(self): + return EventStream() + + def close(self, *_args): + self.close_calls += 1 + + context = BrowserContext(object()) + page_core = PageCore() + page = Page(page_core, context=context, _start_event_pump=False) + page._owns_context = True + context._pages.append(page) + context._rustwright_sync_close_state = "closing" + + page.close() + + assert page_core.close_calls == 1 + assert page.is_closed() + assert context.is_closed() is False + assert context._rustwright_sync_close_state == "closing" + + +def test_context_close_wait_for_concurrent_close_is_bounded(): + from rustwright.sync_api import BrowserContext + + context = BrowserContext(object()) + context._default_timeout = 10.0 + context._rustwright_sync_close_state = "closing" + context._rustwright_sync_close_owner = -1 + errors = [] + + def close_context(): + try: + context.close() + except BaseException as exc: + errors.append(exc) + + closer = threading.Thread(target=close_context, daemon=True) + closer.start() + closer.join(1) + + assert not closer.is_alive() + assert len(errors) == 1 + assert type(errors[0]) is TimeoutError + assert str(errors[0]) == "BrowserContext.close: timed out waiting for concurrent close to finish" + + + +def test_context_new_page_rejects_sync_closing_window(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_started = threading.Event() + self.release_close = threading.Event() + self.new_page_calls = 0 + + def new_page(self): + self.new_page_calls += 1 + raise AssertionError("new_page should be rejected before native creation") + + def close(self): + self.close_started.set() + assert self.release_close.wait(5) + + core = ContextCore() + context = BrowserContext(core) + context.request.dispose = lambda: None + errors = [] + + def close_context(): + try: + context.close() + except BaseException as exc: + errors.append(exc) + + closer = threading.Thread(target=close_context) + closer.start() + assert core.close_started.wait(5) + with pytest.raises(Error, match="BrowserContext is closed"): + context.new_page() + assert core.new_page_calls == 0 + core.release_close.set() + closer.join() + + assert errors == [] + assert context.is_closed() + + +def test_context_new_page_rejects_browser_closing_window(): + from rustwright.sync_api import Browser + + class ContextCore: + def __init__(self): + self.close_started = threading.Event() + self.release_close = threading.Event() + self.new_page_calls = 0 + + def new_page(self): + self.new_page_calls += 1 + raise AssertionError("new_page should be rejected before native creation") + + def close(self): + self.close_started.set() + assert self.release_close.wait(5) + + class BrowserCore: + def __init__(self, context_core): + self.context_core = context_core + + def single_process_fallback(self): + return False + + def new_context(self, _options): + return self.context_core + + def close(self): + pass + + context_core = ContextCore() + browser = Browser(BrowserCore(context_core)) + context = browser.new_context() + context.request.dispose = lambda: None + errors = [] + + def close_browser(): + try: + browser.close() + except BaseException as exc: + errors.append(exc) + + closer = threading.Thread(target=close_browser) + closer.start() + assert context_core.close_started.wait(5) + with pytest.raises(Error, match="BrowserContext is closed"): + context.new_page() + assert context_core.new_page_calls == 0 + context_core.release_close.set() + closer.join() + + assert errors == [] + assert browser._closed + + +def test_context_new_page_rejects_async_closing_window(): + from rustwright.async_api import AsyncBrowserContext + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_started = asyncio.Event() + self.release_close = asyncio.Event() + self.new_page_calls = 0 + + async def new_page_async(self): + self.new_page_calls += 1 + raise AssertionError("new_page should be rejected before native creation") + + async def close_async(self): + self.close_started.set() + await self.release_close.wait() + + async def run(): + core = ContextCore() + sync_context = BrowserContext(core) + sync_context.request.dispose = lambda: None + context = AsyncBrowserContext(sync_context) + + close_task = asyncio.create_task(context.close()) + await asyncio.wait_for(core.close_started.wait(), 5) + with pytest.raises(Error, match="BrowserContext is closed"): + await context.new_page() + assert core.new_page_calls == 0 + core.release_close.set() + await close_task + assert context.is_closed() + + asyncio.run(run()) + + +def test_browser_close_retries_transient_context_failure(): + from rustwright.sync_api import Browser + + class ContextCore: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + raise RuntimeError("transient context disposal failure") + + class BrowserCore: + def __init__(self, context_core): + self.context_core = context_core + self.close_calls = 0 + + def single_process_fallback(self): + return False + + def new_context(self, _options): + return self.context_core + + def close(self): + self.close_calls += 1 + + context_core = ContextCore() + browser = Browser(BrowserCore(context_core)) + context = browser.new_context() + context.request.dispose = lambda: None + + with pytest.raises(Error, match="transient context disposal failure"): + browser.close() + + assert not browser._closed + assert not context.is_closed() + assert context in browser.contexts + assert context_core.close_calls == 1 + assert browser._core.close_calls == 0 + + browser.close() + + assert browser._closed + assert context.is_closed() + assert context_core.close_calls == 2 + assert browser._core.close_calls == 1 + + +def test_context_close_waiter_gets_pre_native_failure_and_fresh_retry(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + self.close_started = threading.Event() + self.release_close = threading.Event() + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + self.close_started.set() + assert self.release_close.wait(5) + raise RuntimeError("transient context disposal failure") + + core = ContextCore() + context = BrowserContext(core) + context.request.dispose = lambda: None + waiter_entered = threading.Event() + original_condition_wait = context._rustwright_sync_close_condition.wait + + def condition_wait(timeout=None): + waiter_entered.set() + return original_condition_wait(timeout) + + context._rustwright_sync_close_condition.wait = condition_wait + errors = [] + + def close_context(label): + try: + context.close() + except BaseException as exc: + errors.append((label, exc)) + + first = threading.Thread(target=close_context, args=("owner",)) + second = threading.Thread(target=close_context, args=("waiter",)) + first.start() + assert core.close_started.wait(5) + second.start() + assert waiter_entered.wait(5) + core.release_close.set() + first.join() + second.join() + + assert {label for label, _ in errors} == {"owner", "waiter"} + owner_error = next(error for label, error in errors if label == "owner") + waiter_error = next(error for label, error in errors if label == "waiter") + assert type(owner_error) is type(waiter_error) is Error + assert str(owner_error) == str(waiter_error) == "transient context disposal failure" + assert waiter_error is not owner_error + assert waiter_error.__cause__ is not None + assert waiter_error.__cause__ is not owner_error + + context.close() + + assert context.is_closed() + assert core.close_calls == 2 + + +def test_context_close_fresh_retry_does_not_steal_waiter_outcome(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + self.close_started = threading.Event() + self.release_close = threading.Event() + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + self.close_started.set() + assert self.release_close.wait(5) + raise RuntimeError("transient context disposal failure") + + core = ContextCore() + context = BrowserContext(core) + context.request.dispose = lambda: None + waiter_ident_ready = threading.Event() + waiter_entered = threading.Event() + waiter_woken = threading.Event() + allow_waiter_return = threading.Event() + waiter_ident = [] + original_condition_wait = context._rustwright_sync_close_condition.wait + + def condition_wait(timeout=None): + if threading.get_ident() == waiter_ident[0]: + waiter_entered.set() + original_condition_wait(timeout) + waiter_woken.set() + context._rustwright_sync_close_condition.release() + try: + assert allow_waiter_return.wait(5) + finally: + context._rustwright_sync_close_condition.acquire() + return + return original_condition_wait(timeout) + + context._rustwright_sync_close_condition.wait = condition_wait + errors = [] + + def owner_close(): + try: + context.close() + except BaseException as exc: + errors.append(("owner", exc)) + + def waiter_close(): + waiter_ident.append(threading.get_ident()) + waiter_ident_ready.set() + try: + context.close() + except BaseException as exc: + errors.append(("waiter", exc)) + + owner = threading.Thread(target=owner_close) + waiter = threading.Thread(target=waiter_close) + owner.start() + assert core.close_started.wait(5) + waiter.start() + assert waiter_ident_ready.wait(5) + assert waiter_entered.wait(5) + core.release_close.set() + assert waiter_woken.wait(5) + + fresh_errors = [] + + def fresh_close(): + try: + context.close() + except BaseException as exc: + fresh_errors.append(exc) + + fresh = threading.Thread(target=fresh_close) + fresh.start() + fresh.join() + allow_waiter_return.set() + owner.join() + waiter.join() + + assert fresh_errors == [] + assert {label for label, _ in errors} == {"owner", "waiter"} + waiter_error = next(error for label, error in errors if label == "waiter") + assert type(waiter_error) is Error + assert str(waiter_error) == "transient context disposal failure" + assert context.is_closed() + assert core.close_calls == 2 + + + +def test_context_close_waiter_gets_post_native_failure_and_reentrant_close_is_safe(): + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + self.close_started = threading.Event() + self.release_close = threading.Event() + + def close(self): + self.close_calls += 1 + self.close_started.set() + assert self.release_close.wait(5) + + core = ContextCore() + context = BrowserContext(core) + context.request.dispose = lambda: None + + def fail_close_handler(closed_context): + closed_context.close() + raise Error("post-native close handler failure") + + context.on("close", fail_close_handler) + waiter_entered = threading.Event() + original_condition_wait = context._rustwright_sync_close_condition.wait + + def condition_wait(timeout=None): + waiter_entered.set() + return original_condition_wait(timeout) + + context._rustwright_sync_close_condition.wait = condition_wait + errors = [] + + def close_context(label): + try: + context.close() + except BaseException as exc: + errors.append((label, exc)) + + first = threading.Thread(target=close_context, args=("owner",)) + second = threading.Thread(target=close_context, args=("waiter",)) + first.start() + assert core.close_started.wait(5) + second.start() + assert waiter_entered.wait(5) + core.release_close.set() + first.join() + second.join() + + assert {label for label, _ in errors} == {"owner", "waiter"} + owner_error = next(error for label, error in errors if label == "owner") + waiter_error = next(error for label, error in errors if label == "waiter") + assert type(owner_error) is type(waiter_error) is Error + assert str(owner_error) == str(waiter_error) == "post-native close handler failure" + assert waiter_error is not owner_error + assert waiter_error.__cause__ is not None + assert context.is_closed() + assert core.close_calls == 1 + + +def test_async_context_close_ignores_target_closed_native_disposal(): + from rustwright.async_api import AsyncBrowserContext + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + + async def close_async(self): + self.close_calls += 1 + raise RuntimeError("Target closed") + + async def run(): + core = ContextCore() + sync_context = BrowserContext(core) + request_dispose_calls = [] + sync_context.request.dispose = lambda: request_dispose_calls.append("dispose") + context = AsyncBrowserContext(sync_context) + + await context.close() + await context.close() + + assert context.is_closed() + assert core.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + asyncio.run(run()) + + +def test_context_close_retries_only_incomplete_cleanup_stages(monkeypatch, tmp_path: Path): + import rustwright.sync_api as sync_api + from rustwright.sync_api import BrowserContext + + har_writes = [] + monkeypatch.setattr(sync_api, "_write_har", lambda *args, **kwargs: har_writes.append((args, kwargs))) + + class ContextCore: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + + class Page: + def __init__(self): + self.close_calls = 0 + + def close(self, **_kwargs): + self.close_calls += 1 + if self.close_calls == 1: + raise Error("transient page cleanup failure") + + core = ContextCore() + page = Page() + context = BrowserContext(core, options={"record_har_path": str(tmp_path / "trace.har")}) + context._pages.append(page) + request_dispose_calls = [] + context.request.dispose = lambda: request_dispose_calls.append("dispose") + + with pytest.raises(Error, match="transient page cleanup failure"): + context.close() + assert not context.is_closed() + assert len(har_writes) == 1 + + context.close() + + assert context.is_closed() + assert len(har_writes) == 1 + assert page.close_calls == 2 + assert request_dispose_calls == ["dispose"] + +def test_async_context_close_retries_native_disposal_without_repeating_cleanup(): + from rustwright.async_api import AsyncBrowserContext + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + + async def close_async(self): + self.close_calls += 1 + if self.close_calls == 1: + raise RuntimeError("transient async context disposal failure") + + class Page: + def __init__(self): + self.close_calls = 0 + + async def close(self, **_kwargs): + self.close_calls += 1 + + async def run(): + core = ContextCore() + page = Page() + sync_context = BrowserContext(core) + sync_context._pages.append(page) + request_dispose_calls = [] + sync_context.request.dispose = lambda: request_dispose_calls.append("dispose") + context = AsyncBrowserContext(sync_context) + + with pytest.raises(Error, match="transient async context disposal failure"): + await context.close() + + assert not context.is_closed() + assert core.close_calls == 1 + assert page.close_calls == 1 + assert request_dispose_calls == [] + + await context.close() + await context.close() + + assert context.is_closed() + assert core.close_calls == 2 + assert page.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + asyncio.run(run()) + + +def test_async_context_close_concurrent_calls_dispose_native_context_once(): + from rustwright.async_api import AsyncBrowserContext + from rustwright.sync_api import BrowserContext + + class ContextCore: + def __init__(self): + self.close_calls = 0 + self.close_started = asyncio.Event() + self.release_close = asyncio.Event() + + async def close_async(self): + self.close_calls += 1 + self.close_started.set() + await self.release_close.wait() + + class Page: + def __init__(self): + self.close_calls = 0 + + async def close(self, **_kwargs): + self.close_calls += 1 + + async def run(): + core = ContextCore() + page = Page() + sync_context = BrowserContext(core) + sync_context._pages.append(page) + request_dispose_calls = [] + sync_context.request.dispose = lambda: request_dispose_calls.append("dispose") + context = AsyncBrowserContext(sync_context) + + first = asyncio.create_task(context.close()) + await core.close_started.wait() + second = asyncio.create_task(context.close()) + await asyncio.sleep(0) + core.release_close.set() + await asyncio.gather(first, second) + + assert context.is_closed() + assert core.close_calls == 1 + assert page.close_calls == 1 + assert request_dispose_calls == ["dispose"] + + asyncio.run(run()) + + @pytest.fixture() def http_proxy_server(): seen: list[tuple[str, str | None]] = [] From 7953e74b0b80f8095a069c3726f5664862a86440 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:44:21 +0000 Subject: [PATCH 3/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/237 --- 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 5c7cd21..03f2013 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", + "_wait_for_page_creations", "_native_context_page", "_finish_native_page", "_run_sync_wait_sliced", From 1103bef1af13eaa4fb84a9c91e70fb030bd1f6db Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:44:21 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'CHANGELOG.?= =?UTF-8?q?md'=20with=20remote=20'CHANGELOG.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/237 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64696a3..0f6fcfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ All notable user-facing changes to Rustwright are documented in this file. ### Fixed - Fixed native drag-and-drop to stop dispatching trailing pointer and mouse release events after a completed drop. +- Hardened sync and async context creation rollback and close disposal retries. ## [0.3.0] - 2026-08-16