Skip to content

Commit d432297

Browse files
committed
test(events): make the timing of the event manager tests deterministic
1 parent ef2b222 commit d432297

2 files changed

Lines changed: 106 additions & 36 deletions

File tree

tests/unit/events/test_event_manager.py

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@
1212

1313
import pytest
1414

15+
from crawlee._utils.wait import wait_for_all_tasks_to_finish
1516
from crawlee.events import Event, EventManager, EventSystemInfoData
1617

1718
if TYPE_CHECKING:
18-
from collections.abc import AsyncGenerator
19+
from collections.abc import AsyncGenerator, Sequence
1920

2021

2122
@pytest.fixture
2223
async 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+
5283
async 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

330361
async 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

352395
async 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

403453
async def test_wait_from_within_multiple_listeners_does_not_deadlock(
@@ -427,39 +477,50 @@ async def second_waiting_listener(_: Any) -> None:
427477

428478

429479
async 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

459518
async 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:

tests/unit/events/test_local_event_manager.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,25 @@
1414
import pytest
1515

1616

17-
async def test_emit_system_info_event() -> None:
17+
async def test_emit_system_info_event(monkeypatch: pytest.MonkeyPatch) -> None:
18+
"""The recurring task emits the first `SystemInfo` event as soon as it starts, without waiting for the interval."""
19+
# Both readings are replaced with instant ones - a real `get_cpu_info` samples the CPU utilization over 100 ms,
20+
# and on a loaded runner it takes far longer than that.
21+
monkeypatch.setattr('crawlee.events._local_event_manager.get_cpu_info', lambda: MagicMock(spec=CpuInfo))
22+
monkeypatch.setattr('crawlee.events._local_event_manager.get_memory_info', lambda: MagicMock(spec=MemoryInfo))
23+
1824
mocked_listener = AsyncMock()
1925
received = asyncio.Event()
2026

2127
async def async_listener(payload: Any) -> None:
2228
await mocked_listener(payload)
2329
received.set()
2430

25-
system_info_interval = timedelta(milliseconds=50)
26-
async with LocalEventManager(system_info_interval=system_info_interval) as event_manager:
31+
# An interval this long means the event can only come from the immediate first run of the recurring task.
32+
async with LocalEventManager(system_info_interval=timedelta(hours=1)) as event_manager:
33+
# Registered before anything yields to the event loop, so the very first emission already reaches it.
2734
event_manager.on(event=Event.SYSTEM_INFO, listener=async_listener)
28-
# Wait until the listener is invoked at least once. A generous timeout avoids flakiness on
29-
# loaded CI runners, where `psutil.cpu_percent(interval=0.1)` in `asyncio.to_thread` can
30-
# delay the first emission well beyond the configured interval.
31-
await asyncio.wait_for(received.wait(), timeout=10)
35+
await asyncio.wait_for(received.wait(), timeout=5)
3236

3337
assert mocked_listener.call_count >= 1
3438
assert isinstance(mocked_listener.call_args[0][0], EventSystemInfoData)

0 commit comments

Comments
 (0)