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