Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/mcp/client/stdio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +158 to +173
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a regression test that exercises the shutdown race by having the subprocess emit at least one complete JSON-RPC line to stdout while the stdio_client context exits quickly, and assert that no ExceptionGroup/BrokenResourceError escapes. Current stdio_client tests cover cleanup timing but don’t appear to trigger the in-flight send failure this change handles.

Copilot uses AI. Check for mistakes.
await anyio.lowlevel.checkpoint()

async def stdin_writer():
Expand Down
39 changes: 39 additions & 0 deletions tests/client/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading