okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petkernel: keep output once, bound snapshots and replays, drop the outbox scanf479db5

main
11.7 KiB311 linesraw
1"""Browser ownership, target routing and explicit event scopes."""
2
3from __future__ import annotations
4
5from typing import SupportsIndex
6
7import asyncio
8import os
9from pathlib import Path
10from typing import Any, TYPE_CHECKING
11from urllib.parse import urlsplit, urlunsplit
12
13import json
14
15from .errors import BrowserError, ConnectionLost, UnsupportedOperation
16from .events import EventScope
17from .page import Page
18from .transport import CDPSession, Connection, JSON, positive
19
20
21if TYPE_CHECKING:
22    from .launch import OwnedChrome
23
24
25class Browser:
26    def __init__(
27        self,
28        connection: Connection,
29        endpoint: str,
30        *,
31        headers: dict[str, str] | None = None,
32    ) -> None:
33        self._connection = connection
34        self.endpoint = endpoint
35        self._headers = dict(headers) if headers else None
36        self._lifecycle_lock = asyncio.Lock()
37        self.version: JSON = {}
38        self.cdp = CDPSession(connection)
39        self._pages: dict[str, Page] = {}
40        self._routes: dict[str, Page] = {}
41        self._contexts: set[str] = set()
42        self._owner: OwnedChrome | None = None
43        self._close_task: asyncio.Task[None] | None = None
44        connection.on_event = self._on_event
45
46    def _on_event(self, method: str, params: JSON, session_id: str | None) -> None:
47        page = self._routes.get(session_id)
48        if page is not None and session_id is not None:
49            page._on_event(method, params, session_id)
50        if method == "Target.detachedFromTarget":
51            sid = params.get("sessionId")
52            self._connection.detach_session(sid)
53            owner = self._routes.get(sid)
54            if owner is not None and isinstance(sid, str):
55                owner._detached(sid)
56                self._routes.pop(sid, None)
57        if method == "Target.targetDestroyed":
58            owner = self._pages.get(params.get("targetId"))
59            if owner is not None:
60                owner._invalidate(destroyed=True)
61
62    async def pages(self) -> list[JSON]:
63        result = await self.cdp.send("Target.getTargets")
64        return [dict(t) for t in result["targetInfos"] if t["type"] == "page"]
65
66    async def attach(self, target_id: str, *, owned: bool = False) -> Page:
67        page = self._pages.get(target_id)
68        if page is not None and not page._destroyed:
69            return page if page.attached else await page.reattach()
70        async with self._lifecycle_lock:
71            # Another attach may have completed while waiting for the lock.
72            page = self._pages.get(target_id)
73            if page is not None and not page._destroyed:
74                if page.attached:
75                    return page
76                async with page._lock:
77                    async with page._settings_lock:
78                        return await page._reattach_locked(
79                            restore_settings=True, max_message_bytes=None
80                        )
81            return await self._attach_new(target_id, owned=owned)
82
83    async def _attach_new(self, target_id: str, *, owned: bool = False) -> Page:
84        result = await self.cdp.send(
85            "Target.attachToTarget", {"targetId": target_id, "flatten": True}
86        )
87        sid = result["sessionId"]
88        page = Page(self, target_id, sid, owned=owned)
89        self._pages[target_id] = page
90        self._routes[sid] = page
91        try:
92            await page._initialize()
93        except BaseException:
94            self._pages.pop(target_id, None)
95            page._invalidate()
96            await page._stop_tasks()
97            try:
98                await self.cdp.send(
99                    "Target.detachFromTarget", {"sessionId": sid}, timeout=2
100                )
101            except Exception:
102                pass
103            raise
104        return page
105
106    async def new_page(
107        self, url: str = "about:blank", *, context_id: str | None = None
108    ) -> Page:
109        params: JSON = {"url": "about:blank"}
110        if context_id is not None:
111            params["browserContextId"] = context_id
112        result = await self.cdp.send("Target.createTarget", params)
113        target_id = result["targetId"]
114        page = await self.attach(target_id, owned=True)
115        if url != "about:blank":
116            await page.goto(url)
117        return page
118
119    async def new_context(self) -> str:
120        """Create isolated session storage. Dispose explicitly; disconnect leaves it alive."""
121        context = (await self.cdp.send("Target.createBrowserContext"))[
122            "browserContextId"
123        ]
124        self._contexts.add(context)
125        return context
126
127    async def dispose_context(self, context_id: str) -> None:
128        if context_id not in self._contexts:
129            raise UnsupportedOperation(
130                "Will not dispose a context this connection did not create."
131            )
132        await self.cdp.send(
133            "Target.disposeBrowserContext", {"browserContextId": context_id}
134        )
135        self._contexts.discard(context_id)
136
137    def events(self, method: str, **options: Any) -> EventScope:
138        return EventScope(self.cdp, method, **options)
139
140    async def disconnect(self) -> None:
141        """Disconnect only. Pages retain identity/settings for explicit reattach."""
142        async with self._lifecycle_lock:
143            await self._disconnect()
144
145    async def _disconnect(self) -> None:
146        for page in list(self._pages.values()):
147            page._invalidate()
148            await page._stop_tasks()
149        await self._connection.close()
150        self._routes.clear()
151
152    async def _ensure_connection(self, max_message_bytes: int | None = None) -> bool:
153        """Called only with the lifecycle lock held. Never launches another process."""
154        old = self._connection
155        if not old.closed:
156            return False
157        for page in list(self._pages.values()):
158            page._invalidate()
159            await page._stop_tasks()
160        self._routes.clear()
161        await old.close()
162        replacement = await connect(
163            self.endpoint,
164            timeout=old.timeout,
165            headers=self._headers,
166            max_message_bytes=(
167                old.max_message_bytes
168                if max_message_bytes is None
169                else max_message_bytes
170            ),
171        )
172        # Transfer only transport ownership; the original Browser retains its
173        # process, profiles, contexts and page handles. There is no await below.
174        self._connection = replacement._connection
175        self.cdp = CDPSession(self._connection)
176        self.version = replacement.version
177        self._connection.on_event = self._on_event
178        return True
179
180    @property
181    def owns_process(self) -> bool:
182        """Whether this handle was returned by spawn(), not connect()."""
183        return self._owner is not None
184
185    @property
186    def pid(self) -> int | None:
187        """Launched PID (retained for diagnostics after close), or None for connect()."""
188        return self._owner.process.pid if self._owner and self._owner.process else None
189
190    @property
191    def profile(self) -> Path | None:
192        """Owned profile path, or None; only temporary profiles are deleted on close."""
193        return self._owner.profile if self._owner else None
194
195    async def _close(self) -> None:
196        async with self._lifecycle_lock:
197            await self._close_locked()
198
199    async def _close_locked(self) -> None:
200        owner = self._owner
201        try:
202            if owner is not None and not self._connection.closed:
203                try:
204                    await self.cdp.send("Browser.close", timeout=2)
205                except BrowserError:
206                    pass  # Closing the browser can close the socket before its reply.
207                await owner.wait(2)
208        finally:
209            try:
210                await self._disconnect()
211            finally:
212                if owner is not None:
213                    await owner.stop()
214
215    async def close(self) -> None:
216        """Disconnect; also stop Chrome and clean temporary data when spawned here.
217
218        Attached browsers are never terminated. Idempotent, including concurrent
219        close calls. Cancellation propagates after bounded cleanup completes.
220        """
221        from .launch import _finish_cleanup
222
223        if self._close_task is None or (
224            self._close_task.done()
225            and (
226                self._close_task.cancelled() or self._close_task.exception() is not None
227            )
228        ):
229            self._close_task = asyncio.create_task(self._close(), name="browser-close")
230        await _finish_cleanup(self._close_task)
231
232    async def __aenter__(self) -> Browser:
233        return self
234
235    async def __aexit__(self, *exc: Any) -> None:
236        await self.close()
237
238    def __reduce_ex__(self, protocol: SupportsIndex, /) -> Any:
239        raise TypeError(
240            "Live browsers cannot be saved; save endpoint and page.descriptor()."
241        )
242
243
244async def connect(
245    endpoint: str | None = None,
246    *,
247    timeout: float = 15,
248    headers: dict[str, str] | None = None,
249    max_message_bytes: int = 16 * 1024 * 1024,
250) -> Browser:
251    """Attach to a browser-level CDP endpoint; do not launch or own its process.
252
253    Accepts an HTTP base URL (discovers /json/version) or a browser WebSocket URL.
254    Headers are sent to discovery and the WebSocket handshake, but never saved in
255    reconnect descriptors. Environment proxies are deliberately disabled.
256    """
257    positive(timeout, "timeout")
258    endpoint = endpoint or os.environ.get(
259        "ALBEDO_BROWSER_CDP_URL", "http://127.0.0.1:9222"
260    )
261    parsed = urlsplit(endpoint)
262    if parsed.scheme not in {"http", "https", "ws", "wss"} or not parsed.hostname:
263        raise ValueError("Expected an http(s) or ws(s) CDP endpoint")
264    ws_url = endpoint
265    if parsed.scheme in {"http", "https"}:
266        path = parsed.path.rstrip("/")
267        if not path.endswith("/json/version"):
268            path += "/json/version"
269        discovery = urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, ""))
270
271        def _get_discovery() -> str:
272            import urllib.request  # one GET at connect time; not worth every kernel's boot
273
274            req = urllib.request.Request(discovery, headers=headers or {})
275            opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
276            with opener.open(req, timeout=timeout) as resp:
277                data = json.loads(resp.read().decode("utf-8"))
278                return str(data["webSocketDebuggerUrl"])
279
280        try:
281            ws_url = await asyncio.to_thread(_get_discovery)
282        except Exception as exc:
283            raise ConnectionLost(
284                "CDP discovery failed. Start Chrome with a dedicated debugging profile, "
285                "or supply a browser WebSocket URL.",
286                cause=type(exc).__name__,
287            ) from exc
288        # Never forward caller-supplied credentials to an arbitrary discovery hostname.
289        discovered = urlsplit(ws_url)
290        if headers and (
291            discovered.hostname != parsed.hostname
292            or (parsed.scheme == "https" and discovered.scheme != "wss")
293        ):
294            raise ConnectionLost(
295                "Discovery returned a different host; use its explicit WebSocket URL with appropriate credentials."
296            )
297    if "/devtools/page/" in ws_url:
298        raise ValueError(
299            "Use the browser endpoint from /json/version, not a page WebSocket URL"
300        )
301    connection = await Connection.open(
302        ws_url, timeout=timeout, headers=headers, max_message_bytes=max_message_bytes
303    )
304    browser = Browser(connection, endpoint, headers=headers)
305    try:
306        browser.version = await browser.cdp.send("Browser.getVersion")
307        await browser.cdp.send("Target.setDiscoverTargets", {"discover": True})
308        return browser
309    except BaseException:
310        await browser.disconnect()
311        raise