From 525855d17331bf9351bd9fe9391c1519d7613c86 Mon Sep 17 00:00:00 2001 From: Helge Milde Date: Wed, 10 Jun 2026 17:05:44 +0200 Subject: [PATCH] Fixed #535: ThreadSensitiveContext.__aexit__ blocking the event loop. executor.shutdown() is a blocking join, and the executor's worker thread may itself be waiting on the event loop, deadlocking it. Join in a separate thread instead, as asyncio's shutdown_default_executor() does. --- CHANGELOG.txt | 7 +++++++ asgiref/sync.py | 8 +++++-- tests/test_sync.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index ee4c69c0..0ee4ea92 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,10 @@ +UNRELEASED +---------- + +* Fixed a deadlock of the entire event loop when exiting + ``ThreadSensitiveContext`` while its executor thread was still blocked + waiting on the event loop. (#535) + 3.11.1 (2026-02-03) ------------------- diff --git a/asgiref/sync.py b/asgiref/sync.py index e733c8e5..37afa133 100644 --- a/asgiref/sync.py +++ b/asgiref/sync.py @@ -144,9 +144,13 @@ async def __aexit__(self, exc, value, tb): return executor = SyncToAsync.context_to_thread_executor.pop(self, None) - if executor: - executor.shutdown() SyncToAsync.thread_sensitive_context.reset(self.token) + if executor: + # The executor's worker thread may itself be waiting for this + # event loop, so a blocking shutdown() here would deadlock it. + # Join in a separate thread instead. + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, executor.shutdown) class AsyncToSync(Generic[_P, _R]): diff --git a/tests/test_sync.py b/tests/test_sync.py index 4199ce65..8effff50 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -690,6 +690,58 @@ async def test_thread_sensitive_context_without_sync_work(): pass +def cancel_inside_thread_sensitive_context(): + """Cancels a thread-sensitive task parked in async_to_sync, then exits the context""" + worker_started = threading.Event() + + async def inner(): + await asyncio.sleep(0.2) + + def sync_code(): + worker_started.set() + async_to_sync(inner)() + + async def main(): + async with ThreadSensitiveContext(): + task = asyncio.create_task( + sync_to_async(sync_code, thread_sensitive=True)() + ) + # Let the executor pick up sync_code, then hold the event loop + # with sync sleeps so the call_soon_threadsafe callback enqueued + # by async_to_sync is still queued when the cancellation lands. + await asyncio.sleep(0) + assert worker_started.wait(5) + time.sleep(0.1) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(main()) + + +def test_thread_sensitive_context_exit_does_not_block_event_loop(): + """ + Tests that exiting ThreadSensitiveContext does not deadlock the event + loop if its executor thread is still parked in AsyncToSync, as happens + when the task running the sync code is cancelled before the event loop + runs the create_task callback enqueued by async_to_sync. (#535) + + Runs in a separate process as the parked executor thread would otherwise + hang the test suite at interpreter exit. + """ + process = multiprocessing.Process(target=cancel_inside_thread_sensitive_context) + process.start() + process.join(30) + # Force cleanup in failed test case + if process.is_alive(): + process.terminate() + process.join(5) + pytest.fail("event loop deadlocked exiting ThreadSensitiveContext") + assert process.exitcode == 0 + + def test_thread_sensitive_double_nested_sync(): """ Tests that thread_sensitive SyncToAsync nests inside itself where the