Skip to content

Commit bb7192b

Browse files
committed
test: cover clean stream end and closed-reader fault forwarding paths
1 parent 082101e commit bb7192b

1 file changed

Lines changed: 46 additions & 0 deletions

File tree

tests/client/test_streamable_http.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
677709
class _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

Comments
 (0)