Skip to content

[v1.x] fix(stdio): handle BrokenResourceError in stdout_reader race (#1960)#2449

Open
ddullah wants to merge 2 commits intomodelcontextprotocol:v1.xfrom
TigerEye-Enterprise:fix/stdio-client-broken-resource-1960
Open

[v1.x] fix(stdio): handle BrokenResourceError in stdout_reader race (#1960)#2449
ddullah wants to merge 2 commits intomodelcontextprotocol:v1.xfrom
TigerEye-Enterprise:fix/stdio-client-broken-resource-1960

Conversation

@ddullah
Copy link
Copy Markdown

@ddullah ddullah commented Apr 15, 2026

Summary

Fixes #1960. The stdio_client in mcp/client/stdio/__init__.py has a race
condition: read_stream_writer.aclose() in the finally block can close the
stream while the background stdout_reader task is mid-send, which raises
anyio.BrokenResourceError and propagates through the task group as an
ExceptionGroup, failing the caller.

Root cause

The two read_stream_writer.send(...) sites inside stdout_reader were not
guarded against BrokenResourceError. The outer except only caught
ClosedResourceError, which is the sibling class raised on already-closed
streams; BrokenResourceError is raised when the receiver is closed during
an in-flight send, which is the exact shape of this shutdown race.

Fix

  • Wrap each read_stream_writer.send(...) in try/except catching both
    ClosedResourceError and BrokenResourceError, then return cleanly.
  • Widen the outer except to the same union for defense in depth.
  • No API changes.

Alternative considered

Issue #1960 suggests tg.cancel_scope.cancel() before the stream closes in
finally. That works but changes shutdown ordering (cancels user-defined
notification handlers, etc.). The chosen fix is narrower: it treats the
shutdown race as a graceful exit from stdout_reader specifically, without
altering the task group lifecycle.

Reproduction

Any stdio MCP server that emits log-channel notifications alongside tool
responses will exhibit this when driven by a downstream wrapper (e.g.,
mcp2cli) that opens a fresh stdio_client per invocation. The task group
exits quickly, the subprocess is still flushing output, and the race is
triggered.

Verification

Drove jules-mcp-server (an MCP server that emits notifications/message
log frames during init) through mcp2cli with a list-schedules tool call.

Before this patch:

ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File ".../mcp/client/stdio/__init__.py", line 162, in stdout_reader
    |     await read_stream_writer.send(session_message)
    |   File ".../anyio/streams/memory.py", line 220, in send_nowait
    |     raise BrokenResourceError
    | anyio.BrokenResourceError

After this patch:

{"count": 0, "schedules": []}
EXIT:0

Closes #1960.

…contextprotocol#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:modelcontextprotocol#1960
Copilot AI review requested due to automatic review settings April 15, 2026 14:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a shutdown race in the stdio transport where stdout_reader could raise anyio.BrokenResourceError during an in-flight send() when the stdio client context exits quickly, causing an ExceptionGroup to escape to callers.

Changes:

  • Catch anyio.BrokenResourceError (in addition to ClosedResourceError) around read_stream_writer.send(...) calls inside stdout_reader.
  • Widen the outer stdout_reader exception handler to also include BrokenResourceError for defense-in-depth.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +158 to +173
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
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.
…kenResourceError 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:modelcontextprotocol#1960
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants