88
99import asyncio
1010import collections
11+ import contextlib
1112import inspect
1213import itertools
1314import json
@@ -77,15 +78,11 @@ def __str__(self):
7778
7879
7980class Transaction :
80- request : dict = None
81- events : list | None = None
82- id : int | None = None
83-
8481 def __init__ (self , request : dict ):
85- self .id = request ["id" ]
86- self .request = request
87- self .result = None
88- self .events = []
82+ self .id : int | None = request ["id" ]
83+ self .request : dict = request
84+ self .events : list = []
85+ self .result : Any = None
8986
9087 def __str__ (self ):
9188 return (
@@ -168,6 +165,24 @@ def last_transaction(self):
168165 """
169166 return self .transactions [- 1 ]
170167
168+ def add_handler (self , event_type : type , callback : Callable ) -> None :
169+ """Register an event callback for a parsed CDP event type."""
170+ if callback not in self .handlers [event_type ]:
171+ self .handlers [event_type ].append (callback )
172+
173+ def remove_handler (self , event_type : type , callback : Callable ) -> bool :
174+ """Remove an event callback; returns True when a handler was removed."""
175+ callbacks = self .handlers .get (event_type )
176+ if not callbacks :
177+ return False
178+ try :
179+ callbacks .remove (callback )
180+ except ValueError :
181+ return False
182+ if not callbacks :
183+ self .handlers .pop (event_type , None )
184+ return True
185+
171186 def __init__ (
172187 self ,
173188 target : TargetType = None ,
@@ -187,6 +202,9 @@ def __init__(
187202 self ._auto_attach = auto_attach
188203 self ._transactions : List [Transaction ] = []
189204 self ._targets : List [Connection ] = []
205+ self ._mapper : dict [int , asyncio .Future ] = {}
206+ self ._tx_by_id : dict [int , Transaction ] = {}
207+ self ._listener_task : asyncio .Task | None = None
190208
191209 #
192210 # @classmethod
@@ -216,14 +234,30 @@ async def aopen(self):
216234 ping_timeout = PING_TIMEOUT ,
217235 max_size = MAX_SIZE ,
218236 )
237+ self ._listener_task = asyncio .create_task (self ._listener ())
219238
220239 async def aclose (self ):
221240 """ """
241+ self ._fail_pending_futures (ConnectionError ("Connection closing" ))
242+ if self ._listener_task is not None :
243+ self ._listener_task .cancel ()
244+ with contextlib .suppress (asyncio .CancelledError ):
245+ await self ._listener_task
246+ self ._listener_task = None
222247 if self .socket :
223248 await self .socket .close ()
224249 await self .socket .wait_closed ()
225250 self .socket = None
226251
252+ def _fail_pending_futures (self , exc : BaseException ) -> None :
253+ for future in self ._mapper .values ():
254+ if not future .done ():
255+ if isinstance (exc , asyncio .CancelledError ):
256+ future .cancel ()
257+ else :
258+ future .set_exception (exc )
259+ self ._mapper .clear ()
260+
227261 async def attach (self , target : TargetType = None ):
228262 """ " """
229263 await self .aopen ()
@@ -372,56 +406,100 @@ async def send(
372406 message ["sessionId" ] = self .session_id
373407 message .update (kwargs )
374408
375- self .transactions .append (Transaction (message ))
409+ tx = Transaction (message )
410+ self .transactions .append (tx )
411+ self ._tx_by_id [_id ] = tx
412+
413+ future = asyncio .get_running_loop ().create_future ()
414+ self ._mapper [_id ] = future
376415
377416 while len (self .transactions ) > 25 :
378417 # clears the oldest request/response pair from transactions
379- self .transactions .pop (0 )
418+ removed_tx = self .transactions .pop (0 )
419+ if removed_tx .id is not None :
420+ self ._tx_by_id .pop (removed_tx .id , None )
421+
422+ ws = self .socket
423+ if ws is None :
424+ raise RuntimeError ("WebSocket is not connected" )
380425
381426 async with self .lock :
382- await self .socket .send (json .dumps (message ))
383- while True :
384- raw = await self .socket .recv ()
385- response_message = json .loads (raw )
386- if not response_message :
387- raise RuntimeError ("no message from stream: %s" % raw )
388- if "result" not in response_message :
389- if "error" in response_message :
390- # set the exception visible in transactions
391- self .transactions [- 1 ].result = ProtocolException (
392- response_message ["error" ]
427+ try :
428+ await ws .send (json .dumps (message ))
429+ except Exception :
430+ self ._mapper .pop (_id , None )
431+ if not future .done ():
432+ future .cancel ()
433+ raise
434+
435+ try :
436+ response_message = await future
437+ finally :
438+ self ._mapper .pop (_id , None )
439+
440+ if "error" in response_message :
441+ exception = ProtocolException (response_message ["error" ])
442+ tx .result = exception
443+ raise exception
444+
445+ try :
446+ cdp_obj .send (response_message ["result" ])
447+ except StopIteration as e :
448+ tx .result = e .value
449+ return e .value
450+ except (Exception ,) as e :
451+ tx .result = e
452+ raise
453+
454+ async def _listener (self ):
455+ ws = self .socket
456+ if ws is None :
457+ raise RuntimeError ("Listener started without an active socket connection." )
458+
459+ while True :
460+ try :
461+ raw = await ws .recv ()
462+ if not raw :
463+ continue
464+ message = json .loads (raw )
465+
466+ if "id" in message :
467+ future = self ._mapper .pop (message ["id" ], None )
468+ if future and not future .done ():
469+ future .set_result (message )
470+ elif future and future .done ():
471+ logger .warning (
472+ "received response for already-completed future id=%s" ,
473+ message .get ("id" ),
393474 )
394- raise self .transactions [- 1 ].result
395-
396- if "method" in response_message :
397- # event
398- try :
399- await self .process_event (response_message , _id )
400- except (Exception ,):
401- raise RuntimeError (
402- "error processing event: %s which occurred during processing of event: %s"
403- % (response_message , message )
404- )
405- else :
406- try :
407- cdp_obj .send (response_message ["result" ])
408- except StopIteration as e :
409- # exception value holds the parsed response
410- self .transactions [- 1 ].result = e .value
411- return e .value
412- except (Exception ,) as e :
413- self .transactions [- 1 ].result = e
414- raise
415- finally :
416- await asyncio .sleep (0 )
417-
418- async def process_event (self , message : dict , id : int ) -> None :
475+ elif "method" in message :
476+ # Unsolicited events are out-of-band unless an explicit tx id is provided.
477+ await self .process_event (message , None )
478+
479+ except websockets .exceptions .ConnectionClosed :
480+ self ._fail_pending_futures (ConnectionError ("Connection closed" ))
481+ break
482+ except asyncio .CancelledError as e :
483+ self ._fail_pending_futures (e )
484+ break
485+ except Exception as e :
486+ logger .error (f"background listener error: { e } " , exc_info = True )
487+
488+ async def process_event (self , message : dict , tx_id : int | None = None ) -> None :
419489 """ """
420490 event = None
421491
422492 try :
423493 event = cdp .util .parse_json_event (message )
424- self .transactions [- 1 ].events .append (event )
494+ tx = self ._tx_by_id .get (tx_id ) if tx_id is not None else None
495+ if tx_id is not None and tx is None :
496+ logger .debug (
497+ "received event for unknown transaction id=%s method=%s" ,
498+ tx_id ,
499+ message .get ("method" ),
500+ )
501+ if tx is not None :
502+ tx .events .append (event )
425503 except KeyError as e :
426504 logger .exception (e )
427505 return
0 commit comments