From f4090bdd9e70cb40b36257ebc9df348750150235 Mon Sep 17 00:00:00 2001 From: weiguangli-io Date: Wed, 11 Mar 2026 11:20:27 +0800 Subject: [PATCH] Fix stdio_client BrokenResourceError race condition during shutdown During shutdown, the finally block closes the receiving end of the read_stream memory channel while stdout_reader may still be blocked on send(). This causes BrokenResourceError (not ClosedResourceError) because the *other* end of the stream was closed. The existing except clause only caught ClosedResourceError, letting BrokenResourceError propagate into an ExceptionGroup. Catch BrokenResourceError alongside ClosedResourceError in both stdout_reader and stdin_writer to handle this race gracefully. Closes #1960 Github-Issue:#1960 Reported-by:maxisbey --- src/mcp/client/stdio.py | 4 ++-- tests/client/test_stdio.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..51fc7db2c 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -158,7 +158,7 @@ async def stdout_reader(): session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: lax no cover + except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: lax no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): @@ -174,7 +174,7 @@ async def stdin_writer(): errors=server.encoding_error_handler, ) ) - except anyio.ClosedResourceError: # pragma: no cover + except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover await anyio.lowlevel.checkpoint() async with anyio.create_task_group() as tg, process: diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index f70c24eee..08c233b2e 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -508,6 +508,44 @@ def handle_term(sig, frame): pass +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::ResourceWarning") +async def test_stdio_client_no_broken_resource_error_on_shutdown(): + """Test that exiting stdio_client without consuming the read stream does not + raise BrokenResourceError. + + Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1960. + The race condition occurs when stdout_reader is blocked on send() into a + zero-buffer memory stream and the finally block closes the receiving end, + causing BrokenResourceError instead of ClosedResourceError. + """ + # Server sends a JSON-RPC message and then sleeps, keeping stdout open. + # The client exits without consuming the read stream, triggering the race. + server_script = textwrap.dedent( + """ + import sys + import time + + sys.stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\\n') + sys.stdout.flush() + time.sleep(5.0) + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", server_script], + ) + + # This should exit cleanly without raising an ExceptionGroup + # containing BrokenResourceError. + with anyio.fail_after(10.0): + async with stdio_client(server_params) as (_read_stream, _write_stream): + # Give stdout_reader time to read the message and block on send() + await anyio.sleep(0.3) + # Exit without consuming read_stream - this triggers the race + + @pytest.mark.anyio async def test_stdio_client_graceful_stdin_exit(): """Test that a process exits gracefully when stdin is closed,