Skip to content

Commit 6d13045

Browse files
committed
Exercise the standalone SSE teardown window deterministically
Drive the between-dequeues teardown path directly through the transport's ASGI entry point with a gated send, so the ClosedResourceError arm is covered by a real test and no longer needs its coverage pragma. The e2e teardown test's docstring now claims only what its assertion proves.
1 parent c0462d2 commit 6d13045

2 files changed

Lines changed: 105 additions & 7 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,11 +717,11 @@ async def standalone_sse_writer():
717717
# Send the message via SSE
718718
event_data = self._create_event_data(event_message)
719719
await sse_stream_writer.send(event_data)
720-
except anyio.ClosedResourceError: # pragma: lax no cover
720+
except anyio.ClosedResourceError:
721721
# Teardown completed while the writer was between dequeues:
722722
# the next receive() hits the closed stream. A writer parked
723723
# in receive() instead sees a clean end-of-stream (cleanup
724-
# closes the send side first), so this arm is timing-dependent.
724+
# closes the send side first).
725725
pass
726726
except Exception:
727727
logger.exception("Error in standalone SSE writer") # pragma: no cover

tests/shared/test_streamable_http.py

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
import anyio
1919
import httpx
2020
import pytest
21+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
2122
from httpx_sse import ServerSentEvent
2223
from starlette.applications import Starlette
2324
from starlette.requests import Request
2425
from starlette.routing import Mount
26+
from starlette.types import Message, Scope
2527

2628
from mcp import MCPError, types
2729
from mcp.client.session import ClientSession
@@ -2231,11 +2233,10 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(context_
22312233
async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pytest.LogCaptureFixture) -> None:
22322234
"""Tearing down the standalone stream under its parked writer produces no error log.
22332235
2234-
Cleanup closes the send side first, so a writer parked in receive() ends on a clean
2235-
end-of-stream. This pins that close ordering: reversing it would wake the parked writer
2236-
with ClosedResourceError on every disconnect. (The timing window where teardown lands
2237-
between dequeues is handled by the writer's ClosedResourceError arm, which cannot be
2238-
forced deterministically from the public surface.)
2236+
SDK-defined teardown behavior, driven through the full client/server path: the writer
2237+
is parked in receive() when teardown lands, and ends quietly. The companion test
2238+
test_standalone_stream_teardown_between_dequeues_is_not_an_error forces the other
2239+
teardown window, which this path cannot reach deterministically.
22392240
"""
22402241
session_manager = StreamableHTTPSessionManager(
22412242
app=_create_server(),
@@ -2267,3 +2268,100 @@ async def message_handler(
22672268
(transport,) = session_manager._server_instances.values() # pyright: ignore[reportPrivateUsage]
22682269
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
22692270
assert "Error in standalone SSE writer" not in caplog.text
2271+
2272+
2273+
@pytest.mark.anyio
2274+
async def test_standalone_stream_teardown_between_dequeues_is_not_an_error(
2275+
caplog: pytest.LogCaptureFixture,
2276+
) -> None:
2277+
"""Teardown landing while the standalone writer is between dequeues produces no error log.
2278+
2279+
SDK-defined: after teardown, the writer's next dequeue hits its own closed stream
2280+
(ClosedResourceError), which is expected disconnect noise, not an error. The public
2281+
surface cannot force this window (the in-process client consumes SSE without
2282+
backpressure, so the writer is always parked in receive() when teardown runs), so this
2283+
drives the transport's ASGI entry point directly with a gated `send`.
2284+
2285+
Steps:
2286+
1. A GET establishes the standalone SSE stream; the gated ASGI send keeps the
2287+
response from consuming any SSE data.
2288+
2. An event sent into the standalone stream rendezvouses with the writer's receive(),
2289+
which then blocks forwarding it to the un-consumed SSE stream -- the
2290+
between-dequeues window.
2291+
3. Stream cleanup runs inside that window, closing both standalone stream ends.
2292+
4. The gate opens: the event reaches the wire, the writer's next dequeue hits the
2293+
closed stream, and the response completes cleanly with nothing logged as an error.
2294+
"""
2295+
transport = StreamableHTTPServerTransport(
2296+
mcp_session_id=None,
2297+
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
2298+
)
2299+
# The GET handler only checks that a read-stream writer exists; the standalone
2300+
# writer never touches it.
2301+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
2302+
transport._read_stream_writer = read_stream_writer # pyright: ignore[reportPrivateUsage]
2303+
2304+
stream_registered = anyio.Event()
2305+
2306+
class SignalingStreams(
2307+
dict[types.RequestId, tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]]]
2308+
):
2309+
# Only the GET handler inserts here, so any insert is the standalone stream
2310+
# registration the test is waiting on.
2311+
def __setitem__(
2312+
self,
2313+
key: types.RequestId,
2314+
value: tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]],
2315+
) -> None:
2316+
super().__setitem__(key, value)
2317+
stream_registered.set()
2318+
2319+
transport._request_streams = SignalingStreams() # pyright: ignore[reportPrivateUsage]
2320+
2321+
gate = anyio.Event()
2322+
sent: list[Message] = []
2323+
2324+
async def asgi_send(message: Message) -> None:
2325+
sent.append(message)
2326+
await gate.wait()
2327+
2328+
# Never delivers anything: parks the response's disconnect listener until the
2329+
# completed response cancels it.
2330+
disconnect_send, disconnect_receive = anyio.create_memory_object_stream[Message](0)
2331+
2332+
async def asgi_receive() -> Message:
2333+
return await disconnect_receive.receive()
2334+
2335+
scope: Scope = {
2336+
"type": "http",
2337+
"method": "GET",
2338+
"path": "/mcp",
2339+
"query_string": b"",
2340+
"headers": [(b"accept", b"text/event-stream")],
2341+
}
2342+
notification = types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
2343+
2344+
async with read_stream_writer, read_stream, disconnect_send, disconnect_receive:
2345+
with anyio.fail_after(5):
2346+
async with anyio.create_task_group() as tg: # pragma: no branch
2347+
tg.start_soon(transport.handle_request, scope, asgi_receive, asgi_send)
2348+
await stream_registered.wait()
2349+
standalone_send = transport._request_streams[GET_STREAM_KEY][0] # pyright: ignore[reportPrivateUsage]
2350+
# Zero-buffer rendezvous: send() returns only once the writer's receive()
2351+
# has taken the event, so the writer is now between dequeues, blocked
2352+
# forwarding to the SSE stream nothing consumes while the gate is closed.
2353+
await standalone_send.send(EventMessage(notification))
2354+
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
2355+
# Unblock the response: it consumes the forwarded event, and the writer's
2356+
# next dequeue hits its closed stream.
2357+
gate.set()
2358+
2359+
# The event dequeued before teardown still reached the wire, and the response
2360+
# ended with a normal completion rather than an exception.
2361+
assert sent[0]["type"] == "http.response.start"
2362+
assert sent[0]["status"] == 200
2363+
body_chunks = [message for message in sent if message["type"] == "http.response.body"]
2364+
assert b"notifications/initialized" in body_chunks[0]["body"]
2365+
assert body_chunks[-1] == {"type": "http.response.body", "body": b"", "more_body": False}
2366+
assert "Error in standalone SSE writer" not in caplog.text
2367+
assert "Error in standalone SSE response" not in caplog.text

0 commit comments

Comments
 (0)