A stop racing CapturedCDP.call() can close the supervisor loop before the queued _send runs; run_coroutine_threadsafe's future then never resolves and fut.result(timeout=None) hangs the consumer thread forever. Poll the unbounded wait and raise CapturedCDPInvalid once the loop is closed. Fold the salvaged validation tests into the dispatch file (one fixture, no F811), and tighten them: the deadline test stalls inside the result grace, socket close asserts CapturedCDPInvalid, the async validator asserts the refusal message.
162 lines
8.0 KiB
Python
162 lines
8.0 KiB
Python
"""Captured CDP connection handle for trusted in-process plugins.
|
|
|
|
``SUPERVISOR_REGISTRY.capture(task_id)`` pins one supervisor's current WebSocket and the
|
|
default page session attached on it. ``call()`` sends only over that socket. Once the
|
|
supervisor reconnects, stops, or is replaced in the registry, the handle is invalid and
|
|
every call raises ``CapturedCDPInvalid``: it never retargets a newer connection.
|
|
|
|
This grants no new capability. In-process plugin code can already reach the supervisor;
|
|
the handle exists so plugins stop writing its private call table and socket (catalog
|
|
review on #132272). It performs no origin, consent or target-ownership checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import inspect
|
|
import math
|
|
import threading
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
|
|
if TYPE_CHECKING:
|
|
from tools.browser_supervisor import CDPSupervisor, _SupervisorRegistry
|
|
|
|
|
|
class CapturedCDPInvalid(RuntimeError):
|
|
"""The captured connection is gone (reconnect, stop, or registry replacement)."""
|
|
|
|
|
|
class CapturedCDP:
|
|
"""A CDP channel pinned to one supervisor connection. Build via ``SUPERVISOR_REGISTRY.capture``."""
|
|
|
|
def __init__(self, supervisor: CDPSupervisor, registry: _SupervisorRegistry) -> None:
|
|
# Runs on the supervisor loop (see ``capture``) so ws and session id are one snapshot.
|
|
self._supervisor = supervisor
|
|
self._registry = registry
|
|
self._loop: asyncio.AbstractEventLoop = supervisor._loop # type: ignore[assignment]
|
|
self._ws = supervisor._ws
|
|
self.task_id: str = supervisor.task_id
|
|
self.cdp_url: str = supervisor.cdp_url
|
|
# The default page session at capture time. A later ``focus_page`` on the supervisor
|
|
# does not change it: calls with this id keep reaching the page captured here.
|
|
self.page_session_id: str = supervisor._page_session_id or ""
|
|
|
|
def is_valid(self) -> bool:
|
|
"""True while the captured connection is still the supervisor's live one."""
|
|
sup = self._supervisor
|
|
return (self._registry.get(self.task_id) is sup and sup._active and not sup._stop_requested
|
|
and sup._ws is self._ws and self._loop.is_running())
|
|
|
|
def _invalid(self) -> CapturedCDPInvalid:
|
|
return CapturedCDPInvalid(f"captured CDP connection for task {self.task_id!r} is gone "
|
|
"(supervisor reconnected, stopped or was replaced); capture again")
|
|
|
|
async def _send(self, method: str, params: Optional[dict[str, Any]],
|
|
session_id: Optional[str], timeout: Optional[float],
|
|
deadline: Optional[float], before_send: Optional[Callable[[], None]]) -> dict[str, Any]:
|
|
# ``_cdp`` reaches its send without awaiting, so this check and the send run in one
|
|
# loop step: a reconnect cannot swap the socket in between.
|
|
if not self.is_valid():
|
|
raise self._invalid()
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
raise TimeoutError("Captured CDP dispatch deadline expired")
|
|
if before_send is not None:
|
|
outcome = before_send()
|
|
if outcome is not None:
|
|
if inspect.iscoroutine(outcome):
|
|
outcome.close()
|
|
raise TypeError("before_send must return None synchronously")
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
raise TimeoutError("Captured CDP dispatch deadline expired")
|
|
if not self.is_valid():
|
|
raise self._invalid()
|
|
try:
|
|
return await self._supervisor._cdp(method, params, session_id=session_id, timeout=timeout)
|
|
except Exception:
|
|
# A reply lost to a dropped socket (timeout, ConnectionError, ConnectionClosed)
|
|
# surfaces as invalidation; a CDP error on a live connection passes through.
|
|
if not self.is_valid():
|
|
raise self._invalid() from None
|
|
raise
|
|
|
|
def call(self, method: str, params: Optional[dict[str, Any]] = None, *,
|
|
session_id: Optional[str] = None, timeout: Optional[float] = 10.0,
|
|
before_send: Optional[Callable[[], None]] = None) -> dict[str, Any]:
|
|
"""Send ``method`` on the captured connection and return the raw CDP reply
|
|
(``{"id", "result"}``). ``session_id=None`` targets the browser endpoint;
|
|
pass ``page_session_id`` (or a session you attached) for page domains.
|
|
|
|
Raises ``CapturedCDPInvalid`` when the connection is gone, ``TimeoutError`` when no
|
|
reply arrives in ``timeout`` seconds, ``RuntimeError`` for a CDP error reply.
|
|
Blocks the calling thread; never call it from the supervisor's own loop.
|
|
|
|
A finite timeout also fences send admission while this call waits in the
|
|
supervisor queue. ``timeout=None`` deliberately retains a reply until
|
|
the connection closes: use it only in a consumer-owned worker when a
|
|
late reply (for example an attachment session ID) must not be lost.
|
|
Keep the consumer's own wait/issuance deadline finite and retain cleanup
|
|
responsibility after it expires.
|
|
|
|
``before_send`` is a trusted synchronous nonblocking validation callback
|
|
run on the supervisor loop immediately before CDP dispatch. It may raise
|
|
to refuse a cancelled/expired operation. It must not call a blocking
|
|
capture/CDP API. This is not origin or target-ownership authorization.
|
|
"""
|
|
if timeout is not None:
|
|
if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
or not math.isfinite(timeout) or timeout <= 0
|
|
or timeout > threading.TIMEOUT_MAX - 1.0):
|
|
raise ValueError("Captured CDP timeout must be positive and finite, or None")
|
|
if before_send is not None and not callable(before_send):
|
|
raise TypeError("before_send must be callable")
|
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
try:
|
|
running = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
running = None
|
|
if running is self._loop:
|
|
raise RuntimeError("CapturedCDP.call() blocks and cannot run on the supervisor loop")
|
|
if not self.is_valid():
|
|
raise self._invalid()
|
|
from agent.async_utils import safe_schedule_threadsafe
|
|
|
|
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout, deadline, before_send), self._loop)
|
|
if fut is None:
|
|
raise self._invalid()
|
|
while timeout is None:
|
|
# Unbounded wait still ends with the loop: a closed loop drops queued callbacks.
|
|
try:
|
|
return fut.result(timeout=1.0)
|
|
except concurrent.futures.TimeoutError:
|
|
if self._loop.is_closed():
|
|
fut.cancel()
|
|
raise self._invalid() from None
|
|
try:
|
|
return fut.result(timeout=timeout + 1.0)
|
|
except concurrent.futures.TimeoutError:
|
|
fut.cancel()
|
|
raise TimeoutError(f"CDP {method} got no reply within {timeout}s") from None
|
|
|
|
|
|
def capture(registry: _SupervisorRegistry, task_id: str, *, timeout: float = 10.0) -> CapturedCDP:
|
|
"""Pin the running supervisor for ``task_id``; never starts, reconnects or refocuses one."""
|
|
from tools.browser_supervisor import _LoopUnavailable, _schedule
|
|
|
|
found = registry.get(task_id)
|
|
loop = found._loop if found is not None else None
|
|
if found is None or loop is None or not loop.is_running():
|
|
raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}")
|
|
sup = found
|
|
|
|
async def _snapshot() -> CapturedCDP:
|
|
if not sup._active or sup._ws is None or not sup._page_session_id:
|
|
raise CapturedCDPInvalid(f"browser supervisor for task {task_id!r} has no attached page")
|
|
return CapturedCDP(sup, registry)
|
|
|
|
try:
|
|
return _schedule(_snapshot(), loop, timeout=timeout)
|
|
except _LoopUnavailable:
|
|
raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}") from None
|