Skip to content

Commit beeb254

Browse files
author
Jianke LIN
committed
test(streamable-http): ensure resumption failure does not hang
1 parent 9bdc03d commit beeb254

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

‎tests/interaction/transports/test_hosting_resume.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,82 @@ async def call() -> None:
373373
assert received == snapshot(["before close", "after close"])
374374

375375

376+
@requirement("hosting:resume:close-stream")
377+
@requirement("transport:streamable-http:resumability")
378+
@requirement("client-transport:http:reconnect-post-priming")
379+
@requirement("client-transport:http:reconnect-retry-value")
380+
async def test_a_call_whose_stream_closes_and_cannot_be_resumed_fails_instead_of_hanging() -> None:
381+
"""If a resumable response stream disconnects and the server session is gone, the client fails
382+
the request instead of hanging forever.
383+
384+
The server closes the call's SSE stream after emitting one related notification. The test then
385+
deletes the active server-side session to force the client's reconnect GET to return 404.
386+
Without a terminal response/error on the read stream, ClientSession.send_request waits forever
387+
(read timeout defaults to None). The transport must surface a request-scoped error when it
388+
gives up reconnecting.
389+
"""
390+
reconnect_attempted = anyio.Event()
391+
allow_exit = anyio.Event()
392+
done = anyio.Event()
393+
raised: list[BaseException] = []
394+
manager_ref = None
395+
deleted_session = False
396+
397+
mcp = MCPServer("resumable")
398+
399+
@mcp.tool()
400+
async def interrupt(ctx: Context) -> str:
401+
await ctx.info("before close")
402+
await ctx.close_sse_stream()
403+
await allow_exit.wait()
404+
return "unreachable"
405+
406+
async def record_request(request: httpx.Request) -> None:
407+
nonlocal deleted_session
408+
if request.method != "GET":
409+
return
410+
if request.headers.get("last-event-id") is None:
411+
return
412+
reconnect_attempted.set()
413+
if deleted_session or manager_ref is None:
414+
return
415+
session_ids = list(manager_ref._server_instances.keys())
416+
if session_ids: # pragma: no branch
417+
del manager_ref._server_instances[session_ids[0]]
418+
deleted_session = True
419+
420+
async with mounted_app(mcp, event_store=SequencedEventStore(), retry_interval=0, on_request=record_request) as (
421+
http,
422+
manager,
423+
):
424+
manager_ref = manager
425+
with anyio.fail_after(5): # pragma: no branch
426+
async with (
427+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, terminate_on_close=False) as (r, w),
428+
ClientSession(r, w) as session,
429+
anyio.create_task_group() as tg,
430+
):
431+
await session.initialize()
432+
433+
async def call() -> None:
434+
try:
435+
await session.call_tool("interrupt", {})
436+
except BaseException as exc:
437+
raised.append(exc)
438+
finally:
439+
done.set()
440+
441+
tg.start_soon(call)
442+
await reconnect_attempted.wait()
443+
await done.wait()
444+
allow_exit.set()
445+
tg.cancel_scope.cancel()
446+
447+
assert len(raised) == 1
448+
assert isinstance(raised[0], Exception)
449+
assert "disconnected" in str(raised[0]).lower()
450+
451+
376452
@requirement("client-transport:http:resume-stream-api")
377453
async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection() -> None:
378454
"""A resumption token captured via on_resumption_token_update on one connection lets a fresh

0 commit comments

Comments
 (0)