2121from contextlib import asynccontextmanager
2222
2323import anyio
24+ import anyio .to_thread
2425import httpx
2526import pytest
2627from starlette .applications import Starlette
@@ -68,6 +69,7 @@ def __init__(self, app: Starlette):
6869 super ().__init__ (daemon = True )
6970 self .app = app
7071 self ._stop_event = threading .Event ()
72+ self ._ready_event = threading .Event ()
7173
7274 def run (self ) -> None :
7375 """Run the lifespan in a new event loop."""
@@ -78,12 +80,19 @@ async def run_lifespan():
7880 lifespan_context = getattr (self .app .router , "lifespan_context" , None )
7981 assert lifespan_context is not None # Tests always create apps with lifespan
8082 async with lifespan_context (self .app ):
83+ # Only signal readiness once lifespan startup has completed, i.e. the
84+ # session manager's task group exists and requests can be handled.
85+ self ._ready_event .set ()
8186 # Wait until stop is requested
8287 while not self ._stop_event .is_set ():
8388 await anyio .sleep (0.1 )
8489
8590 anyio .run (run_lifespan )
8691
92+ def wait_ready (self , timeout : float = 5.0 ) -> None :
93+ """Block until the lifespan has started; call from a worker thread, not the event loop."""
94+ assert self ._ready_event .wait (timeout ), "server thread did not start its lifespan in time"
95+
8796 def stop (self ) -> None :
8897 """Signal the thread to stop."""
8998 self ._stop_event .set ()
@@ -132,8 +141,8 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi
132141 server_thread .start ()
133142
134143 try :
135- # Give the server thread a moment to start
136- await anyio .sleep ( 0.1 )
144+ # Wait for the server thread to enter the lifespan before sending requests
145+ await anyio .to_thread . run_sync ( server_thread . wait_ready )
137146
138147 # Suppress WARNING logs (expected validation errors) and capture ERROR logs
139148 with caplog .at_level (logging .ERROR ):
@@ -203,8 +212,8 @@ async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixt
203212 server_thread .start ()
204213
205214 try :
206- # Give the server thread a moment to start
207- await anyio .sleep ( 0.1 )
215+ # Wait for the server thread to enter the lifespan before sending requests
216+ await anyio .to_thread . run_sync ( server_thread . wait_ready )
208217
209218 # Suppress WARNING logs (expected validation errors) and capture ERROR logs
210219 with caplog .at_level (logging .ERROR ):
@@ -243,8 +252,8 @@ async def test_race_condition_message_router_async_for(caplog: pytest.LogCapture
243252 server_thread .start ()
244253
245254 try :
246- # Give the server thread a moment to start
247- await anyio .sleep ( 0.1 )
255+ # Wait for the server thread to enter the lifespan before sending requests
256+ await anyio .to_thread . run_sync ( server_thread . wait_ready )
248257
249258 # Suppress WARNING logs (expected validation errors) and capture ERROR logs
250259 with caplog .at_level (logging .ERROR ):
0 commit comments