From 20209d643faa32aedaa94d48c98ac69de35f61da Mon Sep 17 00:00:00 2001 From: BashNetCorp Date: Wed, 15 Apr 2026 09:59:58 -0400 Subject: [PATCH 1/2] [v1.x] fix(stdio): handle BrokenResourceError in stdout_reader (#1960) Fix the race condition where `read_stream_writer.aclose()` in the `finally` block of `stdio_client` can close the stream while `stdout_reader` is mid-`send`, producing an unhandled `anyio.BrokenResourceError` that propagates through the task group and surfaces as an `ExceptionGroup` to the caller. Root cause: the two `send` sites in `stdout_reader` did not catch `BrokenResourceError`. The outer `except` only caught `ClosedResourceError`, which is a distinct anyio exception class raised on already-closed streams. `BrokenResourceError` is raised when the receiver is closed while a `send` is in flight, which is the exact shape of this shutdown race. Fix: wrap each `read_stream_writer.send(...)` call in a `try`/`except` that catches both `ClosedResourceError` and `BrokenResourceError`, and return cleanly. Also widen the outer `except` to the same union for defense in depth. Verified by driving a stdio MCP server (jules-mcp-server) through mcp2cli, which previously always surfaced the ExceptionGroup traceback; after the patch, tool calls return cleanly. Github-Issue:#1960 --- src/mcp/client/stdio/__init__.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index 0d76bb958..8d772a7a6 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -155,12 +155,22 @@ async def stdout_reader(): message = types.JSONRPCMessage.model_validate_json(line) except Exception as exc: # pragma: no cover logger.exception("Failed to parse JSONRPC message from server") - await read_stream_writer.send(exc) + try: + await read_stream_writer.send(exc) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + # Context is closing; exit gracefully (issue #1960). + return continue session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: no cover + try: + await read_stream_writer.send(session_message) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + # Context is closing; exit gracefully (issue #1960). + # Happens when the caller exits the stdio_client context + # while the subprocess is still writing to stdout. + return + except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): From a3403d007782dddd53403a5c194c6e801196f4e8 Mon Sep 17 00:00:00 2001 From: BashNetCorp Date: Wed, 15 Apr 2026 10:12:50 -0400 Subject: [PATCH 2/2] [v1.x] test(stdio): regression test for #1960 BrokenResourceError race Covers the fix in the previous commit. Spawns a subprocess that emits a burst of valid JSONRPC notifications, exits the stdio_client context immediately, asserts no exception propagates. Fails before the fix (ExceptionGroup / BrokenResourceError), passes after. Wrapped in anyio.fail_after(5.0) per AGENTS.md. Github-Issue:#1960 --- tests/client/test_stdio.py | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index ba58da732..21fcb13d2 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -33,6 +33,45 @@ async def test_stdio_context_manager_exiting(): pass +@pytest.mark.anyio +async def test_stdio_client_exits_cleanly_while_server_still_writing(): + """Regression test for #1960. + + Exiting the ``stdio_client`` context while the subprocess is still writing to + stdout used to surface ``anyio.BrokenResourceError`` through the task group + (as an ``ExceptionGroup``). The ``finally`` block closes + ``read_stream_writer`` while the background ``stdout_reader`` task is + mid-``send``. + + The fix makes ``stdout_reader`` catch both ``ClosedResourceError`` and + ``BrokenResourceError`` and return cleanly, so exiting the context is a + no-op no matter what the subprocess is doing. + """ + # A server that emits a large burst of valid JSON-RPC notifications without + # ever reading stdin. When we exit the context below, the subprocess is + # still in the middle of that burst, which is the exact shape of the race. + noisy_script = textwrap.dedent( + """ + import sys + for i in range(1000): + sys.stdout.write( + '{"jsonrpc":"2.0","method":"notifications/message",' + '"params":{"level":"info","data":"line ' + str(i) + '"}}\\n' + ) + sys.stdout.flush() + """ + ) + + server_params = StdioServerParameters(command=sys.executable, args=["-c", noisy_script]) + + # The ``async with`` must complete without an ``ExceptionGroup`` / + # ``BrokenResourceError`` propagating. ``anyio.fail_after`` prevents a + # regression from hanging CI. + with anyio.fail_after(5.0): + async with stdio_client(server_params) as (_, _): + pass + + @pytest.mark.anyio @pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_client():