Skip to content

Commit 95d2430

Browse files
Fixed #535: ThreadSensitiveContext.__aexit__ blocking the event loop. (#563)
Run executor shutdown in separate thread to avoid blocking the event loop. Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
1 parent 7c98940 commit 95d2430

3 files changed

Lines changed: 154 additions & 3 deletions

File tree

CHANGELOG.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
UNRELEASED
2+
----------
3+
4+
* Fixed a deadlock of the entire event loop when exiting
5+
``ThreadSensitiveContext`` while its executor thread was still blocked
6+
waiting on the event loop. (#535)
7+
18
3.11.1 (2026-02-03)
29
-------------------
310

asgiref/sync.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import threading
99
import warnings
1010
import weakref
11-
from concurrent.futures import Future, ThreadPoolExecutor
11+
from concurrent.futures import Future, InvalidStateError, ThreadPoolExecutor
1212
from typing import (
1313
TYPE_CHECKING,
1414
Any,
@@ -144,9 +144,25 @@ async def __aexit__(self, exc, value, tb):
144144
return
145145

146146
executor = SyncToAsync.context_to_thread_executor.pop(self, None)
147-
if executor:
148-
executor.shutdown()
149147
SyncToAsync.thread_sensitive_context.reset(self.token)
148+
if executor:
149+
# The executor's worker thread may itself be waiting for this
150+
# event loop, so a blocking shutdown() here would deadlock it.
151+
# Join in a dedicated thread, not the loop's default executor:
152+
# work queued there may itself be needed to unpark the worker,
153+
# and joins occupying its slots would starve it.
154+
future: "Future[None]" = Future()
155+
156+
def join() -> None:
157+
executor.shutdown()
158+
try:
159+
future.set_result(None)
160+
except InvalidStateError:
161+
# The await below was cancelled while we were joining.
162+
pass
163+
164+
threading.Thread(target=join, daemon=True).start()
165+
await asyncio.wrap_future(future)
150166

151167

152168
class AsyncToSync(Generic[_P, _R]):

tests/test_sync.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,134 @@ async def test_thread_sensitive_context_without_sync_work():
690690
pass
691691

692692

693+
def cancel_inside_thread_sensitive_context():
694+
"""Cancels a thread-sensitive task parked in async_to_sync, then exits the context"""
695+
worker_started = threading.Event()
696+
697+
async def inner():
698+
await asyncio.sleep(0.2)
699+
700+
def sync_code():
701+
worker_started.set()
702+
async_to_sync(inner)()
703+
704+
async def main():
705+
async with ThreadSensitiveContext():
706+
task = asyncio.create_task(
707+
sync_to_async(sync_code, thread_sensitive=True)()
708+
)
709+
# Let the executor pick up sync_code, then hold the event loop
710+
# with sync sleeps so the call_soon_threadsafe callback enqueued
711+
# by async_to_sync is still queued when the cancellation lands.
712+
await asyncio.sleep(0)
713+
assert worker_started.wait(5)
714+
time.sleep(0.1)
715+
task.cancel()
716+
try:
717+
await task
718+
except asyncio.CancelledError:
719+
pass
720+
721+
asyncio.run(main())
722+
723+
724+
def test_thread_sensitive_context_exit_does_not_block_event_loop():
725+
"""
726+
Tests that exiting ThreadSensitiveContext does not deadlock the event
727+
loop if its executor thread is still parked in AsyncToSync, as happens
728+
when the task running the sync code is cancelled before the event loop
729+
runs the create_task callback enqueued by async_to_sync. (#535)
730+
731+
Runs in a separate process as the parked executor thread would otherwise
732+
hang the test suite at interpreter exit.
733+
"""
734+
process = multiprocessing.Process(target=cancel_inside_thread_sensitive_context)
735+
process.start()
736+
process.join(30)
737+
# Force cleanup in failed test case
738+
if process.is_alive():
739+
process.terminate()
740+
process.join(5)
741+
pytest.fail("event loop deadlocked exiting ThreadSensitiveContext")
742+
assert process.exitcode == 0
743+
744+
745+
def starve_default_executor_exiting_thread_sensitive_context():
746+
"""
747+
Wedges two shutdown joins and the work they depend on into a two-slot
748+
default executor.
749+
750+
Each request's sync code parks in async_to_sync, and the async side needs
751+
a default-executor slot only after a sleep - by which time both requests
752+
have been cancelled and their context exits occupy both slots.
753+
"""
754+
755+
async def inner():
756+
# Suspend first, so the context exits (and the shutdown joins claim
757+
# their executor slots) before this needs a slot of its own.
758+
await asyncio.sleep(0.3)
759+
await sync_to_async(time.sleep, thread_sensitive=False)(0.05)
760+
761+
def make_sync_code(worker_started):
762+
def sync_code():
763+
worker_started.set()
764+
async_to_sync(inner)()
765+
766+
return sync_code
767+
768+
async def request(worker_started):
769+
async with ThreadSensitiveContext():
770+
task = asyncio.create_task(
771+
sync_to_async(make_sync_code(worker_started), thread_sensitive=True)()
772+
)
773+
# Let the executor pick up sync_code, then hold the event loop
774+
# with sync sleeps so the call_soon_threadsafe callback enqueued
775+
# by async_to_sync is still queued when the cancellation lands.
776+
await asyncio.sleep(0)
777+
assert worker_started.wait(5)
778+
time.sleep(0.1)
779+
task.cancel()
780+
try:
781+
await task
782+
except asyncio.CancelledError:
783+
pass
784+
785+
async def main():
786+
# Two slots stand in for the real default executor's min(32, cpus + 4)
787+
# so two requests are enough to fill it.
788+
asyncio.get_running_loop().set_default_executor(
789+
ThreadPoolExecutor(max_workers=2)
790+
)
791+
await asyncio.gather(request(threading.Event()), request(threading.Event()))
792+
793+
asyncio.run(main())
794+
795+
796+
def test_thread_sensitive_context_exit_does_not_starve_default_executor():
797+
"""
798+
Tests that exiting ThreadSensitiveContext cannot starve the event loop's
799+
default executor. If the shutdown join runs on the default executor,
800+
concurrent context exits can fill it while their parked worker threads
801+
are waiting on work that is queued behind those joins in the same
802+
executor - a circular wait that wedges the executor permanently even
803+
though the event loop stays responsive. (#535)
804+
805+
Runs in a separate process as the wedged threads would otherwise hang
806+
the test suite at interpreter exit.
807+
"""
808+
process = multiprocessing.Process(
809+
target=starve_default_executor_exiting_thread_sensitive_context
810+
)
811+
process.start()
812+
process.join(30)
813+
# Force cleanup in failed test case
814+
if process.is_alive():
815+
process.terminate()
816+
process.join(5)
817+
pytest.fail("default executor starved exiting ThreadSensitiveContext")
818+
assert process.exitcode == 0
819+
820+
693821
def test_thread_sensitive_double_nested_sync():
694822
"""
695823
Tests that thread_sensitive SyncToAsync nests inside itself where the

0 commit comments

Comments
 (0)