okami.mom/albedo

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

nekomimi.petcolab: refresh tokens in the daemon, share the websocket and google oauth code, drop the ssh config editsc9cb3a0

main
13.0 KiB367 linesraw
1"""One reader owns the socket. It never awaits a consumer or user callback."""
2
3from __future__ import annotations
4
5from typing import SupportsIndex
6
7import asyncio
8import copy
9import json
10import math
11from collections.abc import Callable
12from typing import Any
13
14from albedo_plugins.ws import ConnectionClosed, SimpleWebSocketClient
15
16from .errors import (
17    BrowserError,
18    CommandTimeout,
19    ConnectionLost,
20    EventOverflow,
21    ProtocolError,
22    WaitTimeout,
23)
24
25JSON = dict[str, Any]
26
27
28def positive(value: float, name: str) -> float:
29    if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
30        raise ValueError(f"{name} must be finite and positive")
31    return value
32
33
34class Subscription:
35    def __init__(
36        self,
37        connection: Connection,
38        method: str,
39        session_id: str | None,
40        capacity: int,
41        max_bytes: int,
42    ) -> None:
43        if capacity < 1 or max_bytes < 1:
44            raise ValueError("event capacity and max_bytes must be positive")
45        self.connection = connection
46        self.method = method
47        self.session_id = session_id
48        self.capacity = capacity
49        self.max_bytes = max_bytes
50        self._queue: asyncio.Queue[tuple[JSON, int] | None] = asyncio.Queue(capacity)
51        self._bytes = 0
52        self._error: BrowserError | None = None
53        self._closed = False
54
55    def _push(self, params: JSON, size: int) -> None:
56        if self._closed:
57            return
58        if self._queue.full() or self._bytes + size > self.max_bytes:
59            self._fail(
60                EventOverflow(
61                    "Event buffer overflowed; this subscription is no longer complete.",
62                    method=self.method,
63                    capacity=self.capacity,
64                    max_bytes=self.max_bytes,
65                    recovery="Create a new subscription; do not assume no event occurred.",
66                )
67            )
68            return
69        self._queue.put_nowait((copy.deepcopy(params), size))
70        self._bytes += size
71
72    def _fail(self, error: BrowserError) -> None:
73        self._error = error
74        self.close()
75
76    def close(self) -> None:
77        self._closed = True
78        self.connection._subscriptions.discard(self)
79        while not self._queue.empty():
80            self._queue.get_nowait()
81        self._bytes = 0
82        self._queue.put_nowait(None)
83
84    async def next(
85        self,
86        *,
87        where: Callable[[JSON], bool] | None = None,
88        timeout: float = 10,
89    ) -> JSON:
90        """Return one payload. A predicate consumes unmatched events on this stream."""
91        positive(timeout, "timeout")
92        try:
93            async with asyncio.timeout(timeout):
94                while True:
95                    if self._error is not None:
96                        raise self._error
97                    if self._closed:
98                        raise ConnectionLost("Event subscription is closed.")
99                    item = await self._queue.get()
100                    if item is None:
101                        if self._error is not None:
102                            raise self._error
103                        raise ConnectionLost("Event subscription is closed.")
104                    params, size = item
105                    self._bytes -= size
106                    if where is None or where(params):
107                        return params
108        except TimeoutError as exc:
109            raise WaitTimeout(
110                "No matching event before the deadline.", method=self.method
111            ) from exc
112
113    def drain(self) -> list[JSON]:
114        """Take the currently buffered payloads without waiting."""
115        if self._error is not None:
116            raise self._error
117        result = []
118        while not self._queue.empty():
119            item = self._queue.get_nowait()
120            if item is not None:
121                params, size = item
122                self._bytes -= size
123                result.append(params)
124        return result
125
126    def __reduce_ex__(self, protocol: SupportsIndex, /) -> Any:
127        raise TypeError(
128            "Live event subscriptions cannot be saved; drain them to ordinary data."
129        )
130
131
132class Connection:
133    def __init__(
134        self,
135        socket: SimpleWebSocketClient,
136        *,
137        timeout: float,
138        max_message_bytes: int = 16 * 1024 * 1024,
139    ) -> None:
140        self.socket = socket
141        self.max_message_bytes = max_message_bytes
142        self._detached_sessions: set[str] = set()
143        self.timeout = positive(timeout, "timeout")
144        self._next_id = 0
145        self._pending: dict[int, asyncio.Future[JSON]] = {}
146        self._subscriptions: set[Subscription] = set()
147        self._pending_sessions: dict[int, str | None] = {}
148        self._closed: ConnectionLost | None = None
149        self.on_event: Callable[[str, JSON, str | None], None] | None = None
150        self._reader = asyncio.create_task(self._read(), name="browser-cdp-reader")
151
152    @classmethod
153    async def open(
154        cls,
155        url: str,
156        *,
157        timeout: float = 15,
158        headers: dict[str, str] | None = None,
159        max_message_bytes: int = 16 * 1024 * 1024,
160    ) -> Connection:
161        positive(timeout, "timeout")
162        if max_message_bytes < 1024:
163            raise ValueError("max_message_bytes must be at least 1024")
164        try:
165            socket = await SimpleWebSocketClient.connect(
166                url,
167                headers=headers,
168                timeout=timeout,
169                max_message_bytes=max_message_bytes,
170            )
171        except Exception as exc:
172            raise ConnectionLost(
173                "Could not open the CDP WebSocket.", cause=type(exc).__name__
174            ) from exc
175        return cls(socket, timeout=timeout, max_message_bytes=max_message_bytes)
176
177    @property
178    def closed(self) -> bool:
179        return self._closed is not None
180
181    def subscribe(
182        self,
183        method: str,
184        session_id: str | None,
185        *,
186        capacity: int = 256,
187        max_bytes: int = 2 * 1024 * 1024,
188    ) -> Subscription:
189        if self._closed is not None:
190            raise self._closed
191        if session_id in self._detached_sessions:
192            raise ConnectionLost(
193                "Target session detached; use await page.reattach().",
194                session_id=session_id,
195            )
196        if len(self._subscriptions) >= 64:
197            raise ValueError("At most 64 live event subscriptions per connection")
198        sub = Subscription(self, method, session_id, capacity, max_bytes)
199        self._subscriptions.add(sub)
200        return sub
201
202    async def send(
203        self,
204        method: str,
205        params: JSON | None = None,
206        *,
207        session_id: str | None = None,
208        timeout: float | None = None,
209    ) -> JSON:
210        duration = positive(self.timeout if timeout is None else timeout, "timeout")
211        if self._closed is not None:
212            raise self._closed
213        if session_id in self._detached_sessions:
214            raise ConnectionLost(
215                "Target session detached; use await page.reattach().",
216                session_id=session_id,
217            )
218        if not isinstance(method, str) or "." not in method:
219            raise ValueError("method must be a CDP Domain.command name")
220        if len(self._pending) >= 256:
221            raise ValueError("At most 256 in-flight CDP commands per connection")
222        self._next_id += 1
223        request_id = self._next_id
224        request = {"id": request_id, "method": method, "params": params or {}}
225        if session_id is not None:
226            request["sessionId"] = session_id
227        encoded = json.dumps(request, allow_nan=False)
228        future: asyncio.Future[JSON] = asyncio.get_running_loop().create_future()
229        self._pending[request_id] = future
230        self._pending_sessions[request_id] = session_id
231        sent = False
232        try:
233            async with asyncio.timeout(duration):
234                sent = True
235                await self.socket.send(encoded)
236                reply = await future
237            if "error" in reply:
238                error = reply["error"]
239                raise ProtocolError(
240                    error.get("message", "CDP command failed"),
241                    method=method,
242                    code=error.get("code"),
243                    data=error.get("data"),
244                )
245            return reply.get("result", {})
246        except TimeoutError as exc:
247            raise CommandTimeout(
248                "CDP command exceeded its deadline; it was not replayed.",
249                method=method,
250                may_have_executed=sent,
251                timeout=duration,
252            ) from exc
253        except ConnectionClosed as exc:
254            raise ConnectionLost(
255                "CDP connection closed.", method=method, may_have_executed=sent
256            ) from exc
257        finally:
258            self._pending.pop(request_id, None)
259            self._pending_sessions.pop(request_id, None)
260            if not future.done():
261                future.cancel()
262            elif not future.cancelled():
263                future.exception()
264
265    def detach_session(self, session_id: str | None) -> None:
266        if session_id is None:
267            return
268        self._detached_sessions.add(session_id)
269        error = ConnectionLost(
270            "Target session detached; use await page.reattach().", session_id=session_id
271        )
272        for request_id, sid in tuple(self._pending_sessions.items()):
273            if sid == session_id:
274                future = self._pending.get(request_id)
275                if future is not None and not future.done():
276                    future.set_exception(error)
277        for sub in tuple(self._subscriptions):
278            if sub.session_id == session_id:
279                sub._fail(error)
280
281    def _fail(self, error: ConnectionLost) -> None:
282        if self._closed is not None:
283            return
284        self._closed = error
285        for future in self._pending.values():
286            if not future.done():
287                future.set_exception(error)
288        for sub in tuple(self._subscriptions):
289            sub._fail(error)
290
291    async def _read(self) -> None:
292        try:
293            async for raw in self.socket:
294                message = json.loads(raw)
295                if not isinstance(message, dict):
296                    raise ValueError("CDP message is not an object")
297                if "id" in message:
298                    future = self._pending.get(message["id"])
299                    if future is not None and not future.done():
300                        future.set_result(message)
301                    continue
302                method = message.get("method", "")
303                params = message.get("params", {})
304                session_id = message.get("sessionId")
305                if self.on_event is not None:
306                    self.on_event(method, params, session_id)
307                for sub in tuple(self._subscriptions):
308                    if sub.method == method and sub.session_id == session_id:
309                        sub._push(params, len(raw))
310        except asyncio.CancelledError:
311            raise
312        except Exception as exc:
313            details: JSON = {
314                "cause": type(exc).__name__,
315                "max_message_bytes": self.max_message_bytes,
316            }
317            if isinstance(exc, ConnectionClosed):
318                close = (
319                    exc.sent
320                    if exc.sent and exc.sent.code == 1009
321                    else (exc.rcvd or exc.sent)
322                )
323                if close is not None:
324                    details.update(close_code=close.code, close_reason=close.reason)
325            if details.get("close_code") == 1009:
326                message = (
327                    "CDP message exceeded max_message_bytes; event history has a gap. "
328                    "Reattach explicitly with a larger finite max_message_bytes budget."
329                )
330            else:
331                message = "CDP reader stopped; event history has a gap. Use await page.reattach()."
332            self._fail(ConnectionLost(message, **details))
333        finally:
334            self._fail(ConnectionLost("CDP disconnected; event history has a gap."))
335            await self.socket.close()
336
337    async def close(self) -> None:
338        self._fail(ConnectionLost("CDP connection explicitly disconnected."))
339        try:
340            await self.socket.close()
341        finally:
342            self._reader.cancel()
343            await asyncio.gather(self._reader, return_exceptions=True)
344
345    def __reduce_ex__(self, protocol: SupportsIndex, /) -> Any:
346        raise TypeError(
347            "A live CDP connection cannot be saved; save a reconnect descriptor."
348        )
349
350
351class CDPSession:
352    """A session-bound escape hatch. Raw commands bypass high-level safety checks."""
353
354    def __init__(self, connection: Connection, session_id: str | None = None) -> None:
355        self.connection = connection
356        self.session_id = session_id
357
358    async def send(
359        self,
360        method: str,
361        params: JSON | None = None,
362        *,
363        timeout: float | None = None,
364    ) -> JSON:
365        return await self.connection.send(
366            method, params, session_id=self.session_id, timeout=timeout
367        )