fix: stop UploadCache sync wrappers deadlocking inside a running event loop - #6743
fix: stop UploadCache sync wrappers deadlocking inside a running event loop#6743LHMQ878 wants to merge 2 commits into
Conversation
UploadCache._run_sync had a branch for being called while a loop is already running -- the case its docstring advertised as "without blocking event loop" -- that could not succeed. It scheduled the coroutine on that same loop with asyncio.run_coroutine_threadsafe and then blocked on .result(timeout=30). The blocked thread *is* the thread running the target loop, so the loop can never advance the coroutine it was just handed. Every such call stalled for the full 30 seconds and raised TimeoutError. All ten synchronous wrappers funnel through _run_sync -- get, get_by_hash, set, set_by_hash, remove, remove_by_file_id, clear_expired, clear, get_all_for_provider -- so calling any of them from async code (a FastAPI handler, a notebook cell, an async crew callback) hung. UploadCache and cleanup_expired_files are public exports of crewai_files. The coroutine now runs on a worker thread with its own loop, so the thread that blocks is not the thread making progress. The executor is shut down with wait=False rather than via the context-manager form, whose shutdown(wait=True) would block until the worker finished and so make the 30s bound meaningless on the very timeout it guards. Loop affinity is not a concern: the pre-existing else branch already ran each coroutine under a fresh asyncio.run, so the aiocache backend was already required to be loop-agnostic. The memory backend is a plain dict. Reverting only upload_cache.py fails 4 of the 5 new tests, each with concurrent.futures TimeoutError -- the reported defect, not an incidental assertion. 19 passed with the fix (14 pre-existing + 5 new).
📝 WalkthroughWalkthroughChangesUploadCache synchronous execution
Sequence Diagram(s)sequenceDiagram
participant Caller as Calling thread
participant Worker as Worker event-loop thread
participant Coroutine as UploadCache coroutine
Caller->>Worker: submit coroutine
Worker->>Coroutine: execute
Coroutine-->>Caller: return result or exception
Caller->>Worker: cancel after timeout
Worker-->>Caller: signal settlement
Caller->>Worker: stop and join loop
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/crewai-files/src/crewai_files/cache/upload_cache.py`:
- Around line 419-427: Update the executor-based coroutine path around
executor.submit and asyncio.run so a timeout cancels the running asyncio task
instead of abandoning the worker via executor.shutdown(wait=False). Use a
lifecycle-managed worker that bounds execution, propagates cancellation through
nested coroutines, and completes cleanup before returning; update the associated
tests to verify timed-out tasks are cancelled and cleaned up.
In `@lib/crewai-files/tests/test_upload_cache.py`:
- Around line 295-303: Update the running-loop regression test setup to call
cache.set_by_hash for the second cache entry instead of cache.set, while
preserving the existing provider, file identity, and file ID assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5444d5d5-7ec6-4e5a-9b9f-acb3b420be19
📒 Files selected for processing (2)
lib/crewai-files/src/crewai_files/cache/upload_cache.pylib/crewai-files/tests/test_upload_cache.py
CodeRabbit's major finding on this PR, confirmed and worse than stated.
`executor.shutdown(wait=False)` unblocks the caller but cannot stop the
work: `asyncio.run` on a pooled thread is opaque from the outside, the
only handle on it is the thread, and threads cannot be interrupted. So a
coroutine that overran the 30s bound kept running -- and because pool
threads are non-daemon, it also held up interpreter exit until it
finished. Measured on the previous head:
caller unblocked after 0.50s: TimeoutError
1.0s after the timeout, marks = [] <- still running
about to exit; marks = []
interpreter exit took 1.50s, marks = ['COMPLETED AFTER TIMEOUT']
That last line is the part worth fixing rather than documenting: the
coroutine did not merely outlive its caller, it ran to completion during
interpreter teardown and wrote to the shared cache there. A 30s timeout
would have added 30s to the exit of a process that had already given up.
Now the loop is owned here and the coroutine scheduled through
`asyncio.run_coroutine_threadsafe`, which keeps a handle on the task
itself, so the timeout cancels it. Two details the fix turns on:
- The concurrent future is not a "the coroutine has stopped" signal. It
completes when the cancellation is chained to it, while the task may
still be in its own `finally`; stopping the loop then destroys the task
mid-unwind ("Task was destroyed but it is pending"). The flag is set
from inside the coroutine's own `finally` instead, which cannot be early
by construction.
- The thread is a daemon and the loop is left open rather than closed if
the join times out, since closing a loop still in `run_forever` raises.
That path is only reachable when the coroutine ignores cancellation.
Three tests: cancellation of a *running* task (with `started` pinning
that premise -- a short bound can cancel the future before the loop picks
it up, which would pass without anything being interrupted), no thread
outliving a timed-out call, and none outliving a successful one. Thread
leaks are compared by identity across all threads rather than by name
prefix, so an implementation that leaks a differently-named thread cannot
pass. Swapping the old mechanism back in while keeping the constants
fails all three.
Also addresses the minor finding: the wrapper-coverage test now calls
`set_by_hash` rather than `set` twice, so that wrapper is exercised.
Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>
|
Both findings were right. Fixed in The major one:
|
There was a problem hiding this comment.
🧹 Nitpick comments (2)
lib/crewai-files/src/crewai_files/cache/upload_cache.py (2)
486-501: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueDrain async generators before closing the worker loop.
After
run_foreverreturns,worker_loop.close()skipsloop.shutdown_asyncgens(), so any async generator left suspended by a cancelled coroutine is finalized by GC later with an "asynchronous generator was garbage collected" warning. A short bounded drain keeps teardown clean.♻️ Suggested teardown
else: + worker_loop.run_until_complete(worker_loop.shutdown_asyncgens()) worker_loop.close()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/crewai-files/src/crewai_files/cache/upload_cache.py` around lines 486 - 501, Update the worker-loop teardown in the finally block to run a short, bounded async-generator shutdown via worker_loop.shutdown_asyncgens() after the thread has stopped and before worker_loop.close(). Keep the existing timeout and running-thread leak behavior unchanged, and ensure the drain does not block teardown indefinitely.
456-465: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffConsider a shared, lazily created worker loop instead of one per call.
Every in-loop wrapper call spins up a fresh OS thread, selector, and event loop; for hot cache reads this is a lot of setup per lookup. A module-level lazily started worker loop (with the same cancel-on-timeout handle) would amortize it. Note this would require relaxing
test_successful_call_leaves_no_worker_thread_behind/test_timed_out_worker_is_cleaned_up_before_returning, which currently assert no surviving thread, so it's a deliberate tradeoff rather than a defect.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/crewai-files/src/crewai_files/cache/upload_cache.py` around lines 456 - 465, Refactor the in-loop wrapper’s worker management to use a module-level, lazily initialized event loop and worker thread instead of creating them per call. Preserve the existing cancellation-on-timeout handle and update the affected thread-cleanup tests to expect the shared worker’s lifetime rather than asserting no surviving thread after each call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@lib/crewai-files/src/crewai_files/cache/upload_cache.py`:
- Around line 486-501: Update the worker-loop teardown in the finally block to
run a short, bounded async-generator shutdown via
worker_loop.shutdown_asyncgens() after the thread has stopped and before
worker_loop.close(). Keep the existing timeout and running-thread leak behavior
unchanged, and ensure the drain does not block teardown indefinitely.
- Around line 456-465: Refactor the in-loop wrapper’s worker management to use a
module-level, lazily initialized event loop and worker thread instead of
creating them per call. Preserve the existing cancellation-on-timeout handle and
update the affected thread-cleanup tests to expect the shared worker’s lifetime
rather than asserting no surviving thread after each call.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9fbf449b-ddd7-4099-a6a6-da3d19880dac
📒 Files selected for processing (2)
lib/crewai-files/src/crewai_files/cache/upload_cache.pylib/crewai-files/tests/test_upload_cache.py
Description
UploadCache._run_sync's branch for being called while an event loop is already running could not succeed. It scheduled the coroutine on the caller's own running loop withasyncio.run_coroutine_threadsafe, then blocked that same thread on.result(timeout=30). The blocked thread is the thread running the target loop, so the loop can never advance the coroutine it was just handed. Every such call stalled for the full 30 seconds and raisedTimeoutError.The docstring claimed the opposite of the behaviour: "Run an async coroutine from sync context without blocking event loop."
Fixes #6742
Root cause
lib/crewai-files/src/crewai_files/cache/upload_cache.py:402-413onmain(ebe0082):asyncio.run_coroutine_threadsafeis for submitting work to a loop running in a different thread.loophere came fromasyncio.get_running_loop(), so it is this thread's loop — the precondition is inverted.All ten synchronous wrappers funnel through
_run_sync(get,get_by_hash,set,set_by_hash,remove,remove_by_file_id,clear_expired,clear,get_all_for_provider), andUploadCache/cleanup_expired_filesare public exports ofcrewai_files. So any of them called from a FastAPI handler, a notebook cell, or an async crew callback hung for 30s and then raised.The change
The coroutine runs on a worker thread with its own loop, so the thread that blocks is not the thread that has to make progress:
The
wait=Falseshutdown is deliberate rather than stylistic. Writing this aswith ThreadPoolExecutor(...) as executor:reads better but reintroduces the bug it is fixing on the timeout path:__exit__callsshutdown(wait=True), which blocks until the worker finishes, so a coroutine that overran the 30s bound would still hang the caller indefinitely — the bound would only appear to be enforced.Deliberately not changed:
elsebranch is untouched. With no running loop,asyncio.run(coro)was already correct.elsepath.Loop affinity was checked before choosing this shape: the pre-existing
elsebranch already ran every coroutine under a freshasyncio.run, so theaiocachebackend was already required to be loop-agnostic, and the default memory backend isCache(serializer=PickleSerializer(), namespace=namespace)— a plain dict.Tests
lib/crewai-files/tests/test_upload_cache.pyhad no async coverage at all, which is why a branch that always timed out went unnoticed. Five tests added inTestRunSyncFromInsideAnEventLoop:test_set_and_get_outside_event_looptest_set_and_get_inside_running_event_loopset/getfrom async code complete, bounded by a wall clock assertion so a stall fails with a diagnosis rather than just a timeouttest_sync_wrappers_do_not_use_the_callers_looptest_remaining_sync_wrappers_inside_running_event_loopget_all_for_provider,get_by_hash,remove,remove_by_file_id,clear_expired,clear) share_run_syncand are covered tootest_exceptions_propagate_from_inside_running_event_loopControl experiment
Reverting only
upload_cache.pyand keeping the tests:The 4 failures are exactly the four in-loop tests, and each fails at
concurrent/futures/_base.py:458: TimeoutError— the reported defect, not an incidental assertion. The sync baseline and all 14 pre-existing tests pass unchanged.Worth flagging for review: that control run takes ~2 minutes because the unfixed code stalls for its 30s timeout on each affected test rather than failing fast. The wall clock bound in
test_set_and_get_inside_running_event_loop(elapsed < 10) exists so the headline regression reports the cause rather than only timing out.Verification
lib/crewai-files/tests/test_upload_cache.pyruff check/ruff format --checkon both changed filesmypy lib/crewai-files/src/crewai_files/cache/upload_cache.pyOn Windows these tests need
--allowed-hosts=127.0.0.1,::1(theProactorEventLoopself-pipe usessocket.socketpair(), whichpytest-recordingblocks) and a workinglibmagic; both are pre-existing environment concerns unrelated to this change.