Skip to content

Commit ae9ae53

Browse files
reimplement add_handler and update lifecycle safety + determinstic event handling (#38)
Co-authored-by: Meepen <meep@meepen.dev>
1 parent d5ad287 commit ae9ae53

1 file changed

Lines changed: 125 additions & 47 deletions

File tree

‎nodriver/core/connection.py‎

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import asyncio
1010
import collections
11+
import contextlib
1112
import inspect
1213
import itertools
1314
import json
@@ -77,15 +78,11 @@ def __str__(self):
7778

7879

7980
class 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

Comments
 (0)