@@ -674,6 +674,38 @@ def handler(request: httpx2.Request) -> httpx2.Response:
674674 assert "connection reset" in reply .message .error .message
675675
676676
677+ class _EmptySSEStream (httpx2 .AsyncByteStream ):
678+ """An SSE stream that ends cleanly before yielding any events."""
679+
680+ async def __aiter__ (self ) -> AsyncIterator [bytes ]:
681+ return
682+ yield # pragma: no cover
683+
684+
685+ @pytest .mark .anyio
686+ async def test_a_stream_that_ends_cleanly_without_a_response_keeps_the_plain_message () -> None :
687+ """A per-request SSE stream that ends cleanly (no events, no fault) resolves the
688+ waiter with the plain CONNECTION_CLOSED message."""
689+
690+ def handler (request : httpx2 .Request ) -> httpx2 .Response :
691+ return httpx2 .Response (200 , headers = {"content-type" : "text/event-stream" }, stream = _EmptySSEStream ())
692+
693+ with anyio .fail_after (5 ):
694+ async with (
695+ httpx2 .AsyncClient (transport = httpx2 .MockTransport (handler )) as http ,
696+ streamable_http_client ("http://test/mcp" , http_client = http ) as (read , write ),
697+ ):
698+ await write .send (
699+ SessionMessage (JSONRPCRequest (jsonrpc = "2.0" , id = "listen-1" , method = "subscriptions/listen" , params = {}))
700+ )
701+ reply = await read .receive ()
702+ assert isinstance (reply , SessionMessage )
703+ assert isinstance (reply .message , JSONRPCError )
704+ assert reply .message .id == "listen-1"
705+ assert reply .message .error .code == CONNECTION_CLOSED
706+ assert reply .message .error .message == "SSE stream ended without a response"
707+
708+
677709class _DeliverOnCommandSSEStream (httpx2 .AsyncByteStream ):
678710 """Parks after opening, then delivers one JSON-RPC response when told."""
679711
@@ -795,3 +827,17 @@ async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contain
795827 _abandoned_request_context (http , send ), "evt-7" , None , MAX_RECONNECTION_ATTEMPTS
796828 )
797829 send .close ()
830+
831+
832+ @pytest .mark .anyio
833+ async def test_forwarding_a_stream_fault_after_the_reader_closed_is_contained () -> None :
834+ """Teardown race: forwarding a fault after the reader closed is best-effort and must not crash."""
835+ transport = StreamableHTTPTransport ("http://test/mcp" )
836+ send , receive = create_context_streams [SessionMessage | Exception ](1 )
837+ receive .close ()
838+ async with httpx2 .AsyncClient () as http :
839+ with anyio .fail_after (5 ):
840+ await transport ._forward_stream_fault ( # pyright: ignore[reportPrivateUsage]
841+ send , httpx2 .ReadError ("connection reset" )
842+ )
843+ send .close ()
0 commit comments