fix(events): prevent deadlock when closing or waiting for listeners from within a listener - #2088
Merged
Merged
Conversation
…rom within a listener
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2088 +/- ##
=======================================
Coverage 93.57% 93.57%
=======================================
Files 181 181
Lines 12590 12644 +54
=======================================
+ Hits 11781 11832 +51
- Misses 809 812 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Pijukatel
reviewed
Jul 22, 2026
Mantisus
approved these changes
Aug 4, 2026
…exception handler
B4nan
approved these changes
Aug 5, 2026
vdusek
added a commit
to apify/apify-sdk-python
that referenced
this pull request
Aug 5, 2026
…ee lock crawlee's EventManager now handles this deadlock upstream (apify/crawlee-python#2088), so the SDK-side workaround is redundant. The lockfile is bumped to crawlee 1.9.1b4, which contains the fix; the regression test passes without any SDK-side code changes. The declared crawlee constraint in pyproject.toml stays >=1.8.0,<2.0.0 until crawlee ships a stable release with the fix.
vdusek
added a commit
that referenced
this pull request
Aug 6, 2026
…lease (#2100) Get rid of `pyee` as we really don't need it and modernize the `EventManager` and `LocalEventManager`. No public API changes. ### Fixes - A listener whose `__call__` is async (a class instance, not a function) was treated as sync and handed to `asyncio.to_thread`, which returned its coroutine without ever awaiting it - the listener silently never ran. - `off()` decided between "remove this listener" and "remove all listeners of the event" by the truthiness of `listener`, so a listener object that is falsy in a boolean context wiped all listeners of the event. - `off()` left an empty entry behind in `_listeners_to_wrappers`, keeping a reference to a listener that is no longer registered, and created entries for events and listeners that were never registered at all. - A listener that fits neither call shape (it takes two parameters, say) raised out of its own task instead of being logged like any other failing listener. Whether the listener takes the event data is now resolved once at registration, so the invocation itself is fully covered by the logging. - `__aexit__` released the context only on its happy path. A cancellation or a failing emission left the manager active for good, and since re-entering an active manager is a no-op, `PersistState` was never emitted again. ### pyee is gone `emit` creates the listener task itself instead of going through `pyee.asyncio.AsyncIOEventEmitter`, which: - halves the tasks per invocation - the emitter used to schedule a wrapper task that spawned and awaited a second, inner listener task, - registers every listener task synchronously in `emit`, so `wait_for_all_listeners_to_complete()` can no longer miss the listeners of a just-emitted event - the one-tick defer that #2088 had to make explicit is not needed anymore, - drops a runtime dependency (`pyee` is still pulled in by `playwright` for the browser extras). The `_listeners_to_wrappers` layout is deliberately kept as it is - apify-sdk-python reaches into it in `Actor.reboot()`. Its full unit test suite passes against this branch. ### The rest - Everything that depends only on the listener (whether it takes the event data, sync/async, its name) is resolved once at registration instead of on every invocation, and the wrapping moved out of `on()` into `_wrap_listener`. - The two `__aexit__` branches were collapsed into a single `last_exit` flag, `__aenter__` mirrors it, and the manual "not active" check was replaced by the `ensure_context` decorator, so the message matches the one every other method raises. - The three `EventManager.on.listener_wrapper(): ...` DEBUG lines are gone - they reported that a task is awaited, that it completed, and that it was discarded. - `LocalEventManager` reads the CPU and the memory info concurrently - `get_cpu_info` alone blocks its thread for 100 ms while sampling the CPU. - Typing: `__aenter__ -> Self` and `set[asyncio.Task[None]]`. - `wait_for_all_tasks_for_finish` renamed to `wait_for_all_tasks_to_finish`. - The listener helpers are private static methods of `EventManager`, and the attributes of both managers are documented with docstrings instead of comments. New tests cover every fix above, the synchronous task registration in `emit`, the nested context teardown, and the concurrent system info readings. *✍️ Drafted by Claude Code*
vdusek
added a commit
to apify/apify-sdk-python
that referenced
this pull request
Aug 7, 2026
…ers (#1061) `Actor.exit()` / `Actor.fail()` called from within an event listener used to deadlock into a `RecursionError`, since the cleanup path waited on the very listener task that called it. Fixed upstream in crawlee's `EventManager`: [apify/crawlee-python#2088](apify/crawlee-python#2088) (merged). On Python 3.11 it still deadlocks, because `asyncio.wait_for` there wraps the awaited coroutine in a separate task, which defeats crawlee's self-wait detection. This is a minor edge case, so we're not adding an SDK-side workaround just for Python 3.11. This PR: - Adds a regression test for `Actor.exit()` called from an `ABORTING` listener, skipped on Python 3.11. - Bumps `uv.lock` (not the declared `pyproject.toml` constraint) to `crawlee==1.9.1b4`, so CI exercises the fix. Follow-up: bump the declared `crawlee` constraint in `pyproject.toml` once a stable release with the fix ships. *✍️ Drafted by Claude Code*
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Calling
EventManager.wait_for_all_listeners_to_complete()- or closing the manager via__aexit__- from within an event listener deadlocked. The listener runs in a task that is itself registered in_listener_tasks, so the wait ends up awaiting the very task that is awaiting it. Under a close timeout this cycle degrades further into aRecursionError.Changes in
EventManager:wait_for_all_listeners_to_complete()are tracked in_waiting_listener_tasksand excluded from the wait, so listener waiters never await themselves or each other. A caller that is not a listener is outside the cycle and still awaits every listener, waiting ones included.emitonly schedules the listener wrappers, and each registers its listener task once it starts running, so the wait yields before snapshotting_listener_tasks. This also drops theEvent listener raised an exception.log line, which duplicated the ERROR the listener wrapper already logs with the traceback.finallyusesset.discard()instead ofset.remove(), since__aexit__may have already cleared the task set while the listener was mid-flight (avoids a spuriousKeyError).Regression tests cover waiting from within a listener, several listeners waiting at once, closing the manager from within a listener, and waiting from outside while a listener is itself waiting.
This unblocks apify/apify-sdk-python#1061, where
Actor.exit()is called from inside an event listener (e.g. anABORTINGhandler) - with this fix the SDK can drop its_detach_current_listener_taskworkaround.✍️ Drafted by Claude Code