@@ -317,12 +317,24 @@ async def mock_receive():
317317
318318@pytest .mark .anyio
319319async def test_idle_session_is_reaped ():
320- """Idle timeout sets a cancel scope deadline and reaps the session when it fires."""
321- idle_timeout = 300
320+ """After idle timeout fires, the session returns 404."""
322321 app = Server ("test-idle-reap" )
323- manager = StreamableHTTPSessionManager (app = app , session_idle_timeout = idle_timeout )
322+
323+ run_finished = anyio .Event ()
324+ original_run = app .run
325+
326+ async def tracked_run (* args : Any , ** kwargs : Any ) -> None :
327+ try :
328+ await original_run (* args , ** kwargs )
329+ finally :
330+ run_finished .set ()
331+
332+ app .run = tracked_run # type: ignore[assignment]
333+
334+ manager = StreamableHTTPSessionManager (app = app , session_idle_timeout = 300 )
324335
325336 async with manager .run ():
337+ # Create a session
326338 sent_messages : list [Message ] = []
327339
328340 async def mock_send (message : Message ):
@@ -338,7 +350,6 @@ async def mock_send(message: Message):
338350 async def mock_receive (): # pragma: no cover
339351 return {"type" : "http.request" , "body" : b"" , "more_body" : False }
340352
341- before = anyio .current_time ()
342353 await manager .handle_request (scope , mock_receive , mock_send )
343354
344355 session_id = None
@@ -352,35 +363,54 @@ async def mock_receive(): # pragma: no cover
352363 break
353364
354365 assert session_id is not None , "Session ID not found in response headers"
355- assert session_id in manager ._server_instances
356366
357- # Verify the idle deadline was set correctly
367+ # Force the idle deadline to expire
358368 transport = manager ._server_instances [session_id ]
359369 assert transport .idle_scope is not None
360- assert transport .idle_scope .deadline >= before + idle_timeout
361-
362- # Simulate time passing by expiring the deadline
363370 transport .idle_scope .deadline = anyio .current_time ()
364371
372+ # Wait for app.run to exit via the cancel scope, then one checkpoint for cleanup
365373 with anyio .fail_after (5 ):
366- while session_id in manager ._server_instances :
367- await anyio .sleep (0 )
368-
369- assert session_id not in manager ._server_instances
370-
371- # Verify terminate() is idempotent
372- await transport .terminate ()
373- assert transport .is_terminated
374-
375-
376- @pytest .mark .parametrize (
377- "kwargs,match" ,
378- [
379- ({"session_idle_timeout" : - 1 }, "positive number" ),
380- ({"session_idle_timeout" : 0 }, "positive number" ),
381- ({"session_idle_timeout" : 30 , "stateless" : True }, "not supported in stateless" ),
382- ],
383- )
384- def test_session_idle_timeout_validation (kwargs : dict [str , Any ], match : str ):
385- with pytest .raises (ValueError , match = match ):
386- StreamableHTTPSessionManager (app = Server ("test" ), ** kwargs )
374+ await run_finished .wait ()
375+ await anyio .sleep (0 )
376+
377+ # Verify session is gone via public API: request with old session ID returns 404
378+ response_messages : list [Message ] = []
379+ response_body = b""
380+
381+ async def capture_send (message : Message ):
382+ nonlocal response_body
383+ response_messages .append (message )
384+ if message ["type" ] == "http.response.body" :
385+ response_body += message .get ("body" , b"" )
386+
387+ scope_with_session = {
388+ "type" : "http" ,
389+ "method" : "POST" ,
390+ "path" : "/mcp" ,
391+ "headers" : [
392+ (b"content-type" , b"application/json" ),
393+ (b"mcp-session-id" , session_id .encode ()),
394+ ],
395+ }
396+
397+ await manager .handle_request (scope_with_session , mock_receive , capture_send )
398+
399+ response_start = next (
400+ (msg for msg in response_messages if msg ["type" ] == "http.response.start" ),
401+ None ,
402+ )
403+ assert response_start is not None
404+ assert response_start ["status" ] == 404
405+
406+
407+ def test_session_idle_timeout_rejects_non_positive ():
408+ with pytest .raises (ValueError , match = "positive number" ):
409+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = - 1 )
410+ with pytest .raises (ValueError , match = "positive number" ):
411+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 0 )
412+
413+
414+ def test_session_idle_timeout_rejects_stateless ():
415+ with pytest .raises (RuntimeError , match = "not supported in stateless" ):
416+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 30 , stateless = True )
0 commit comments