Files
hermes-agent/tools/browser_supervisor_capture.py
Teknium 66e0cee526 fix(browser): end timeout=None captured waits when the supervisor loop closes
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.
2026-10-09 02:04:29 -07:00

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