Skip to content

Commit f171a4e

Browse files
committed
fix: prevent deadlock when Actor.exit() is called from an event listener
1 parent 58d6da1 commit f171a4e

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

src/apify/_actor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,24 @@ async def finalize() -> None:
283283
except Exception:
284284
self.log.exception('Failed to save Actor state')
285285

286+
# When `exit()` / `fail()` is called from within an event listener (e.g. an `ABORTING` handler), that
287+
# listener's own task is tracked in the event manager's listener-task set. The cleanup below waits for
288+
# all listener tasks to finish -- directly, and again inside the event manager shutdown -- which would
289+
# deadlock on the caller's own task and then, on the timeout cancellation, recurse into cancelling it,
290+
# raising `RecursionError`. Detach it for the duration so the waits ignore it, then restore it so its
291+
# wrapper can deregister it normally.
292+
current_task = asyncio.current_task()
293+
current_task_is_listener = current_task is not None and current_task in self.event_manager._listener_tasks # noqa: SLF001
294+
if current_task_is_listener:
295+
self.event_manager._listener_tasks.discard(current_task) # noqa: SLF001
296+
286297
try:
287298
await asyncio.wait_for(finalize(), self._cleanup_timeout.total_seconds())
288299
except TimeoutError:
289300
self.log.exception('Actor cleanup timed out')
290301
finally:
302+
if current_task_is_listener:
303+
self.event_manager._listener_tasks.add(current_task) # noqa: SLF001
291304
self._active = False
292305

293306
if reraise_control_flow:

tests/unit/actor/test_actor_lifecycle.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import contextlib
55
import json
66
import logging
7-
from datetime import UTC, datetime
7+
from datetime import UTC, datetime, timedelta
88
from typing import TYPE_CHECKING, Any
99
from unittest import mock
1010
from unittest.mock import AsyncMock, Mock
@@ -14,7 +14,7 @@
1414
import websockets.asyncio.server
1515

1616
from apify_client._models import Run
17-
from crawlee.events._types import Event, EventPersistStateData
17+
from crawlee.events._types import Event, EventAbortingData, EventPersistStateData
1818

1919
from ..._utils import poll_until_condition
2020
from apify import Actor
@@ -112,6 +112,29 @@ async def test_fail_properly_deinitializes_actor(actor: _ActorType) -> None:
112112
assert actor._active is False
113113

114114

115+
async def test_exit_from_event_listener_completes_cleanup(caplog: pytest.LogCaptureFixture) -> None:
116+
"""`Actor.exit()` called from an event listener runs cleanup instead of deadlocking into a RecursionError."""
117+
actor = Actor(exit_process=False)
118+
await actor.init()
119+
120+
exit_returned = False
121+
122+
async def on_aborting(_data: EventAbortingData) -> None:
123+
nonlocal exit_returned
124+
await actor.exit(event_listeners_timeout=timedelta(seconds=1))
125+
exit_returned = True
126+
127+
actor.on(Event.ABORTING, on_aborting)
128+
actor.event_manager.emit(event=Event.ABORTING, event_data=EventAbortingData())
129+
130+
await poll_until_condition(lambda: not actor._active, timeout=5, poll_interval=0.1)
131+
132+
assert exit_returned, 'Actor.exit() never returned inside the listener (deadlocked).'
133+
assert actor._active is False
134+
assert actor.event_manager.active is False
135+
assert 'RecursionError' not in caplog.text
136+
137+
115138
async def test_failed_charging_manager_init_does_not_leak_event_manager() -> None:
116139
"""Test that a failure in the charging manager's `__aenter__` also exits the already-entered event manager."""
117140
actor = Actor()

0 commit comments

Comments
 (0)