@@ -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+
693821def test_thread_sensitive_double_nested_sync ():
694822 """
695823 Tests that thread_sensitive SyncToAsync nests inside itself where the
0 commit comments