From 55936172ee0ab9b09c3c5749d0099b1004499847 Mon Sep 17 00:00:00 2001 From: Lovish Arora <46993225+lavish0000@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:25:46 +0100 Subject: [PATCH 1/3] fix: avoid stdio cleanup BrokenResourceError race --- src/mcp/client/stdio.py | 11 +++++++---- tests/client/test_stdio.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..365836052 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -205,10 +205,13 @@ async def stdin_writer(): except ProcessLookupError: # pragma: no cover # Process already exited, which is fine pass - await read_stream.aclose() - await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() + # Stop background stream tasks before closing the memory streams they use. + tg.cancel_scope.cancel() + + await read_stream.aclose() + await write_stream.aclose() + await read_stream_writer.aclose() + await write_stream_reader.aclose() def _get_executable_command(command: str) -> str: diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index f70c24eee..68e4006c3 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -155,6 +155,35 @@ async def test_stdio_client_universal_cleanup(): ) +@pytest.mark.anyio +async def test_stdio_client_cleanup_cancels_backpressured_stdout_reader(): + """Regression test for issue #1960. + + Exiting the client without consuming the read stream leaves stdout_reader + blocked on a zero-buffer send. Cleanup must cancel the task before closing + its memory stream. + """ + script_content = textwrap.dedent( + """ + import sys + import time + + sys.stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\\n') + sys.stdout.flush() + time.sleep(2.0) + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", script_content], + ) + + with anyio.fail_after(5.0): + async with stdio_client(server_params) as (_, _): + await anyio.sleep(0.2) + + @pytest.mark.anyio @pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different") async def test_stdio_client_sigint_only_process(): # pragma: lax no cover From 00920b55818850cbbca7857b8c2684ad3c5f026a Mon Sep 17 00:00:00 2001 From: Lovish Arora <46993225+lavish0000@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:34:44 +0100 Subject: [PATCH 2/3] fix: handle broken stdio streams during cleanup --- src/mcp/client/stdio.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 365836052..e1557d029 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.BrokenResourceError, anyio.ClosedResourceError): # 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.BrokenResourceError, anyio.ClosedResourceError): # pragma: no cover await anyio.lowlevel.checkpoint() async with anyio.create_task_group() as tg, process: @@ -205,13 +205,10 @@ async def stdin_writer(): except ProcessLookupError: # pragma: no cover # Process already exited, which is fine pass - # Stop background stream tasks before closing the memory streams they use. - tg.cancel_scope.cancel() - - await read_stream.aclose() - await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() + await read_stream.aclose() + await write_stream.aclose() + await read_stream_writer.aclose() + await write_stream_reader.aclose() def _get_executable_command(command: str) -> str: From 54a97908cabb91033956545258eb83e1f2333d08 Mon Sep 17 00:00:00 2001 From: Lovish Arora <46993225+lavish0000@users.noreply.github.com> Date: Fri, 6 Mar 2026 06:44:47 +0100 Subject: [PATCH 3/3] chore: retrigger CI after cancelled workflow