Skip to content

Commit 4731187

Browse files
author
Jianke LIN
committed
test: cover EOF drain branches
1 parent 8cdc81d commit 4731187

2 files changed

Lines changed: 59 additions & 1 deletion

File tree

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,9 @@ async def run(
530530
# Cancel on crash/cancel paths. If read EOF also closed
531531
# writes, handlers cannot drain responses anyway.
532532
tg.cancel_scope.cancel()
533-
elif self._read_eof_drain_timeout_seconds is not None:
533+
elif self._read_eof_drain_timeout_seconds is None:
534+
pass
535+
else:
534536
tg.cancel_scope.deadline = anyio.current_time() + self._read_eof_drain_timeout_seconds
535537
finally:
536538
# Covers cancel/crash paths that skip the inline fan-out; idempotent.

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,62 @@ async def drive() -> None:
369369
s2c_recv.close()
370370

371371

372+
@pytest.mark.anyio
373+
async def test_run_closes_write_stream_after_clean_eof_without_drain_timeout():
374+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
375+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
376+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
377+
c2s_recv,
378+
s2c_send,
379+
close_write_stream_on_read_close=False,
380+
read_eof_drain_timeout_seconds=None,
381+
)
382+
on_request, on_notify = echo_handlers(Recorder())
383+
384+
with anyio.fail_after(5):
385+
async with anyio.create_task_group() as tg, c2s_send, c2s_recv, s2c_send, s2c_recv:
386+
await tg.start(server.run, on_request, on_notify)
387+
c2s_send.close()
388+
with pytest.raises(anyio.EndOfStream): # pragma: no branch
389+
await s2c_recv.receive()
390+
391+
392+
@pytest.mark.anyio
393+
async def test_run_drains_in_flight_handlers_on_clean_eof_without_timeout():
394+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
395+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
396+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
397+
c2s_recv,
398+
s2c_send,
399+
close_write_stream_on_read_close=False,
400+
read_eof_drain_timeout_seconds=None,
401+
)
402+
handler_started = anyio.Event()
403+
handler_allowed_to_finish = anyio.Event()
404+
405+
async def handle_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
406+
handler_started.set()
407+
await handler_allowed_to_finish.wait()
408+
return {"drained": True}
409+
410+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
411+
raise NotImplementedError
412+
413+
with anyio.fail_after(5):
414+
async with anyio.create_task_group() as tg, c2s_send, c2s_recv, s2c_send, s2c_recv:
415+
await tg.start(server.run, handle_request, on_notify)
416+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="x", params=None)))
417+
await handler_started.wait()
418+
c2s_send.close()
419+
handler_allowed_to_finish.set()
420+
421+
response = await s2c_recv.receive()
422+
assert isinstance(response, SessionMessage)
423+
assert isinstance(response.message, JSONRPCResponse)
424+
assert response.message.id == 1
425+
assert response.message.result == {"drained": True}
426+
427+
372428
@pytest.mark.anyio
373429
async def test_run_closes_write_stream_on_exit():
374430
"""run() owns both streams; the write end is released once the EOF teardown completes."""

0 commit comments

Comments
 (0)