1212
1313import pytest
1414
15+ from crawlee ._utils .wait import wait_for_all_tasks_to_finish
1516from crawlee .events import Event , EventManager , EventSystemInfoData
1617
1718if TYPE_CHECKING :
18- from collections .abc import AsyncGenerator
19+ from collections .abc import AsyncGenerator , Sequence
1920
2021
2122@pytest .fixture
2223async def event_manager () -> AsyncGenerator [EventManager , None ]:
23- async with EventManager () as event_manager :
24+ # The teardown waits for the listeners left running by the test, so cap it - without a timeout, a listener that
25+ # never completes hangs the whole run instead of failing the test that left it behind.
26+ async with EventManager (close_timeout = timedelta (seconds = 5 )) as event_manager :
2427 yield event_manager
2528
2629
@@ -49,6 +52,34 @@ def sync_listener(payload: Any) -> None:
4952 return sl
5053
5154
55+ class ListenerWaitSpy :
56+ """Observes the waits the event manager makes, patched in over the real waiting helper on construction.
57+
58+ It lets a test see which tasks a wait awaits and act at the exact moment a wait starts, so that ordering does
59+ not have to be approximated by sleeping.
60+ """
61+
62+ def __init__ (self , monkeypatch : pytest .MonkeyPatch ) -> None :
63+ self .awaited_tasks : list [set [asyncio .Task [None ]]] = []
64+ """Tasks awaited by each of the waits, in the order the waits started."""
65+
66+ self .wait_started = asyncio .Event ()
67+ """Set by a wait right before it starts awaiting, so once it is observed, the waiter is already blocked."""
68+
69+ monkeypatch .setattr ('crawlee.events._event_manager.wait_for_all_tasks_to_finish' , self .wait )
70+
71+ async def wait (
72+ self ,
73+ tasks : Sequence [asyncio .Task [None ]],
74+ * ,
75+ logger : logging .Logger ,
76+ timeout : timedelta | None = None ,
77+ ) -> None :
78+ self .awaited_tasks .append (set (tasks ))
79+ self .wait_started .set ()
80+ await wait_for_all_tasks_to_finish (tasks , logger = logger , timeout = timeout )
81+
82+
5283async def test_emit_invokes_registered_sync_listener (
5384 sync_listener : MagicMock ,
5485 event_manager : EventManager ,
@@ -329,24 +360,36 @@ async def raise_error() -> None:
329360
330361async def test_wait_for_all_listeners_cancelled_error (
331362 monkeypatch : pytest .MonkeyPatch ,
332- caplog : pytest .LogCaptureFixture ,
333363 event_system_info_data : EventSystemInfoData ,
334364) -> None :
335- # Simulate long-running listener tasks
336- async def long_running_listener () -> None :
337- await asyncio .sleep (10 )
338-
339- # Define a side effect function that raises CancelledError
340- async def mock_async_wait (* _ : Any , ** __ : Any ) -> None :
365+ """A cancelled wait must cancel the listeners it was awaiting and let the cancellation propagate."""
366+ never_set = asyncio .Event ()
367+ listener_cancelled = asyncio .Event ()
368+
369+ async def never_ending_listener () -> None :
370+ try :
371+ await never_set .wait ()
372+ except asyncio .CancelledError :
373+ listener_cancelled .set ()
374+ raise
375+
376+ async def cancel_the_wait (* _ : Any , ** __ : Any ) -> None :
341377 raise asyncio .CancelledError
342378
343- with pytest .raises (asyncio .CancelledError ), caplog .at_level (logging .WARNING ): # noqa: PT012
344- async with EventManager (close_timeout = timedelta (milliseconds = 10 )) as event_manager :
345- event_manager .on (event = Event .SYSTEM_INFO , listener = long_running_listener )
346- event_manager .emit (event = Event .SYSTEM_INFO , event_data = event_system_info_data )
379+ event_manager = EventManager ()
380+ await event_manager .__aenter__ ()
381+ event_manager .on (event = Event .SYSTEM_INFO , listener = never_ending_listener )
382+ event_manager .emit (event = Event .SYSTEM_INFO , event_data = event_system_info_data )
383+
384+ # Only `asyncio.wait` is replaced, so the cancellation is still handled by the real waiting code - which is what
385+ # has to cancel the listener that never finishes on its own.
386+ monkeypatch .setattr ('asyncio.wait' , cancel_the_wait )
387+
388+ # Capped, as a listener left uncancelled would be awaited forever - the close has no timeout of its own here.
389+ with pytest .raises (asyncio .CancelledError ):
390+ await asyncio .wait_for (event_manager .__aexit__ (None , None , None ), timeout = 5 )
347391
348- # Use monkeypatch to replace asyncio.wait with mock_async_wait
349- monkeypatch .setattr ('asyncio.wait' , mock_async_wait )
392+ assert listener_cancelled .is_set ()
350393
351394
352395async def test_methods_raise_error_when_not_active (event_system_info_data : EventSystemInfoData ) -> None :
@@ -375,16 +418,20 @@ async def test_wait_for_all_listeners_from_within_a_listener_does_not_deadlock(
375418 event_system_info_data : EventSystemInfoData ,
376419) -> None :
377420 """Waiting from within a listener must not self-await, yet must still await the other listeners."""
421+ parked = asyncio .Event ()
422+ release_other_listener = asyncio .Event ()
378423 other_listener_done = asyncio .Event ()
379424 waiter_done = asyncio .Event ()
380425 other_done_when_wait_returned : bool | None = None
381426
382427 async def other_listener (_ : Any ) -> None :
383- await asyncio . sleep ( 0.2 )
428+ await release_other_listener . wait ( )
384429 other_listener_done .set ()
385430
386431 async def waiting_listener (_ : Any ) -> None :
387432 nonlocal other_done_when_wait_returned
433+ # Set right before the wait, which registers the waiter and captures the tasks without yielding in between.
434+ parked .set ()
388435 await event_manager .wait_for_all_listeners_to_complete ()
389436 other_done_when_wait_returned = other_listener_done .is_set ()
390437 waiter_done .set ()
@@ -393,11 +440,14 @@ async def waiting_listener(_: Any) -> None:
393440 event_manager .on (event = Event .SYSTEM_INFO , listener = waiting_listener )
394441 event_manager .emit (event = Event .SYSTEM_INFO , event_data = event_system_info_data )
395442
443+ # The other listener finishes only once the waiter is already blocked, so the wait cannot pass it by.
444+ await asyncio .wait_for (parked .wait (), timeout = 5 )
445+ release_other_listener .set ()
446+
396447 await asyncio .wait_for (waiter_done .wait (), timeout = 5 )
397448
398449 # No self-await deadlock, and the wait must have blocked until the co-registered listener finished.
399450 assert other_done_when_wait_returned is True
400- assert other_listener_done .is_set ()
401451
402452
403453async def test_wait_from_within_multiple_listeners_does_not_deadlock (
@@ -427,39 +477,50 @@ async def second_waiting_listener(_: Any) -> None:
427477
428478
429479async def test_wait_from_outside_awaits_a_listener_that_is_itself_waiting (
480+ monkeypatch : pytest .MonkeyPatch ,
430481 event_manager : EventManager ,
431482 event_system_info_data : EventSystemInfoData ,
432483) -> None :
433484 """A caller that is not a listener is outside the deadlock cycle, so it must await even waiting listeners."""
485+ spy = ListenerWaitSpy (monkeypatch )
434486 parked = asyncio .Event ()
487+ release_other_listener = asyncio .Event ()
435488 waiting_listener_done = asyncio .Event ()
489+ waiter_task : asyncio .Task [None ] | None = None
436490
437491 async def other_listener (_ : Any ) -> None :
438- await asyncio . sleep ( 0.1 )
492+ await release_other_listener . wait ( )
439493
440494 async def waiting_listener (_ : Any ) -> None :
495+ nonlocal waiter_task
496+ waiter_task = asyncio .current_task ()
497+ # Set right before the wait, which registers the waiter and captures the tasks without yielding in between.
441498 parked .set ()
442499 await event_manager .wait_for_all_listeners_to_complete ()
443- # Work done after the inner wait returns - the outer wait must not return before it finishes.
444- await asyncio .sleep (0.1 )
445500 waiting_listener_done .set ()
446501
447502 event_manager .on (event = Event .SYSTEM_INFO , listener = other_listener )
448503 event_manager .on (event = Event .SYSTEM_INFO , listener = waiting_listener )
449504 event_manager .emit (event = Event .SYSTEM_INFO , event_data = event_system_info_data )
450505
451- # `parked` is set right before the listener registers itself as a waiter, without yielding in between.
452506 await asyncio .wait_for (parked .wait (), timeout = 5 )
453507
508+ # The release has to come from outside this task, as the wait below blocks until the listeners are done. Scheduled
509+ # on the loop, it runs only once that wait has suspended - so the wait captures the tasks with both still running.
510+ asyncio .get_running_loop ().call_soon (release_other_listener .set )
454511 await asyncio .wait_for (event_manager .wait_for_all_listeners_to_complete (), timeout = 5 )
455512
513+ # The waiter's own wait went first, so the second one is the outer wait - and it had to include the parked waiter.
514+ assert waiter_task in spy .awaited_tasks [1 ]
456515 assert waiting_listener_done .is_set ()
457516
458517
459518async def test_close_from_within_a_listener_does_not_deadlock_or_error (
519+ monkeypatch : pytest .MonkeyPatch ,
460520 event_system_info_data : EventSystemInfoData ,
461521) -> None :
462522 """Closing the event manager from within a listener (as `Actor.exit()` does) must not deadlock or raise."""
523+ spy = ListenerWaitSpy (monkeypatch )
463524 event_manager = EventManager ()
464525 await event_manager .__aenter__ ()
465526
@@ -468,30 +529,35 @@ async def test_close_from_within_a_listener_does_not_deadlock_or_error(
468529 asyncio .get_running_loop ().set_exception_handler (lambda _loop , context : loop_errors .append (context ))
469530
470531 closed = asyncio .Event ()
532+ release_other_listener = asyncio .Event ()
471533 other_listener_done = asyncio .Event ()
534+ closing_task : asyncio .Task [None ] | None = None
472535
473536 async def other_listener (_ : Any ) -> None :
474- await asyncio . sleep ( 0.2 )
537+ await release_other_listener . wait ( )
475538 other_listener_done .set ()
476539
477540 async def closing_listener (_ : Any ) -> None :
541+ nonlocal closing_task
542+ closing_task = asyncio .current_task ()
478543 await event_manager .__aexit__ (None , None , None )
479544 closed .set ()
480545
481546 # A second listener makes close await a concurrently-running listener - the real `Actor.exit()` shape.
482547 event_manager .on (event = Event .SYSTEM_INFO , listener = other_listener )
483548 event_manager .on (event = Event .SYSTEM_INFO , listener = closing_listener )
484-
485- tasks_before = asyncio .all_tasks ()
486549 event_manager .emit (event = Event .SYSTEM_INFO , event_data = event_system_info_data )
487550
488551 try :
552+ # The close is already blocked in its wait here, so releasing the other listener only now means the close
553+ # really does have to await a listener that is still running.
554+ await asyncio .wait_for (spy .wait_started .wait (), timeout = 5 )
555+ release_other_listener .set ()
556+
489557 await asyncio .wait_for (closed .wait (), timeout = 5 )
490- # Drain the wrapper tasks so their `finally` blocks run before we assert - no arbitrary sleep.
491- spawned = asyncio .all_tasks () - tasks_before - {asyncio .current_task ()}
492- if spawned :
493- # Capped, as the set is derived from all the running tasks - an unrelated one must not hang the test.
494- await asyncio .wait (spawned , timeout = 5 )
558+ # Let the closing listener's own task finalize before asserting - its wrapper returns right after `closed`.
559+ assert closing_task is not None
560+ await asyncio .wait_for (closing_task , timeout = 5 )
495561 finally :
496562 # Cap the cleanup so a regressed deadlock surfaces the real failure instead of hanging.
497563 if event_manager .active :
0 commit comments