@@ -2514,3 +2514,90 @@ async def call() -> None:
25142514 for stream in (c2s_send , c2s_recv , s2c_send , s2c_recv ):
25152515 stream .close ()
25162516 assert result_box == [{"ok" : True }]
2517+
2518+
2519+ @pytest .mark .anyio
2520+ async def test_transport_exception_fails_pending_request_without_hanging ():
2521+ """A read-stream fault wakes an in-flight `send_raw_request` with `CONNECTION_CLOSED`.
2522+
2523+ Regression for the streamable-http hang: before this, a transport error (e.g. an SSE
2524+ read timeout) reached only the observer, so a request already waiting on its response
2525+ sat until its own `opts["timeout"]` elapsed. Now the dispatcher fails the waiter at once,
2526+ and the error message carries the transport exception so the caller can see the cause.
2527+ """
2528+ c2s_send , c2s_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](4 )
2529+ s2c_send , s2c_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](4 )
2530+
2531+ client : JSONRPCDispatcher [TransportContext ] = JSONRPCDispatcher (c2s_recv , s2c_send )
2532+ on_request , on_notify = echo_handlers (Recorder ())
2533+ outcome : dict [str , BaseException ] = {}
2534+ boom = TimeoutError ("sse read timed out" )
2535+ try :
2536+ async with anyio .create_task_group () as tg :
2537+ await tg .start (client .run , on_request , on_notify )
2538+
2539+ async def call () -> None :
2540+ # No `timeout` opt: without the fix this would block forever.
2541+ try :
2542+ await client .send_raw_request ("tools/call" , {"name" : "slow" })
2543+ except BaseException as exc : # noqa: BLE001 - capture whatever the waiter raises
2544+ outcome ["exc" ] = exc
2545+
2546+ tg .start_soon (call )
2547+ # Let the request register in `_pending` and park on its response.
2548+ with anyio .fail_after (5 ):
2549+ sent = await s2c_recv .receive ()
2550+ assert isinstance (sent , SessionMessage )
2551+ assert isinstance (sent .message , JSONRPCRequest )
2552+
2553+ # The transport now yields an exception instead of a response.
2554+ await c2s_send .send (boom )
2555+ with anyio .fail_after (5 ):
2556+ while "exc" not in outcome :
2557+ await anyio .sleep (0 )
2558+ tg .cancel_scope .cancel ()
2559+ finally :
2560+ for s in (c2s_send , c2s_recv , s2c_send , s2c_recv ):
2561+ s .close ()
2562+
2563+ raised = outcome ["exc" ]
2564+ assert isinstance (raised , MCPError )
2565+ assert raised .error .code == CONNECTION_CLOSED
2566+ # The original transport exception is preserved in the message for debugging.
2567+ assert "sse read timed out" in raised .error .message
2568+
2569+
2570+ def test_fail_pending_reports_transport_exception_and_clears_pending ():
2571+ """White-box: `_fail_pending` wakes waiters with the exception detail, then empties `_pending`."""
2572+ c2s_send , c2s_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](1 )
2573+ s2c_send , s2c_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](1 )
2574+ d : JSONRPCDispatcher [TransportContext ] = JSONRPCDispatcher (s2c_recv , c2s_send )
2575+ send , recv = anyio .create_memory_object_stream [dict [str , Any ] | ErrorData ](1 )
2576+ d ._pending [1 ] = _Pending (send = send , receive = recv ) # pyright: ignore[reportPrivateUsage]
2577+
2578+ d ._fail_pending (TimeoutError ("sse read timed out" )) # pyright: ignore[reportPrivateUsage]
2579+
2580+ signalled = recv .receive_nowait ()
2581+ assert isinstance (signalled , ErrorData )
2582+ assert signalled .code == CONNECTION_CLOSED
2583+ assert "sse read timed out" in signalled .message
2584+ assert d ._pending == {} # pyright: ignore[reportPrivateUsage]
2585+ for s in (c2s_send , c2s_recv , s2c_send , s2c_recv , send , recv ):
2586+ s .close ()
2587+
2588+
2589+ def test_fail_pending_keeps_existing_outcome_when_waiter_already_resolved ():
2590+ """White-box: a waiter that already holds a real result is not clobbered by the fault signal."""
2591+ c2s_send , c2s_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](1 )
2592+ s2c_send , s2c_recv = anyio .create_memory_object_stream [SessionMessage | Exception ](1 )
2593+ d : JSONRPCDispatcher [TransportContext ] = JSONRPCDispatcher (s2c_recv , c2s_send )
2594+ send , recv = anyio .create_memory_object_stream [dict [str , Any ] | ErrorData ](1 )
2595+ d ._pending [1 ] = _Pending (send = send , receive = recv ) # pyright: ignore[reportPrivateUsage]
2596+ send .send_nowait ({"real" : "result" })
2597+
2598+ d ._fail_pending (TimeoutError ("sse read timed out" )) # pyright: ignore[reportPrivateUsage]
2599+
2600+ assert recv .receive_nowait () == {"real" : "result" }
2601+ assert d ._pending == {} # pyright: ignore[reportPrivateUsage]
2602+ for s in (c2s_send , c2s_recv , s2c_send , s2c_recv , send , recv ):
2603+ s .close ()
0 commit comments