Skip to content

fix(client/stdio): allow FIFO cleanup of multiple transports on asyncio - #3375

Closed
jwilson411 wants to merge 1 commit into
modelcontextprotocol:mainfrom
jwilson411:oss/UP-11/20260824155844
Closed

fix(client/stdio): allow FIFO cleanup of multiple transports on asyncio#3375
jwilson411 wants to merge 1 commit into
modelcontextprotocol:mainfrom
jwilson411:oss/UP-11/20260824155844

Conversation

@jwilson411

Copy link
Copy Markdown

Fixes #577.

What I hit

I reproduced this on current main (56af447) with two independent AsyncExitStacks around stdio_client, closed oldest-first. Same RuntimeError:

RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
RuntimeError: Attempted to exit a cancel scope that isn't the current task's current cancel scope

The in-process FakeProcess seam in tests/client/test_stdio.py is enough; no real MCP server required. Stashing only src/mcp/client/stdio.py after this change makes test_two_transports_held_by_one_task_close_in_either_order[oldest-first] fail with that same error.

Why

stdio_client still wraps the pipe tasks in anyio.create_task_group() inside the async context manager. That cancel scope is bound to the task that entered the generator. anyio then requires LIFO exit of those scopes in that task. Independent stacks / multi-client managers / unordered fixtures do not share one LIFO, so teardown crashes.

Fix

On asyncio, _run_pipe_tasks starts the reader/writer with asyncio.ensure_future so they are not stacked on the caller's cancel-scope. The existing process-and-stream teardown sequence is unchanged (close stdin, grace, terminate tree, close streams). Trio still uses an anyio task group and still requires LIFO — trio cannot spawn a task outside a nursery.

Unhandled pipe-task failures are collected and re-raised after teardown; they are not swallowed.

Tests

  • test_two_transports_held_by_one_task_close_in_either_order — oldest-first and newest-first, two independent AsyncExitStacks, FakeProcess only.
  • test_an_unhandled_pipe_task_failure_surfaces_out_of_the_context_manager — undecodable stdout still surfaces UnicodeDecodeError.

uv run --frozen pytest tests/client/test_stdio.py → 37 passed.
Coverage on src/mcp/client/stdio.py is 100% (branch) including the trio task-group path. strict-no-cover is clean.

Notes

#2484 already proposed an asyncio-create_task approach in April 2026 but is unreviewed and the stdio file has been rewritten since then (there is now a dedicated teardown helper). This is a fresh patch against current main, not a rebase of that PR.

AI-assisted implementation (Claude Code / Opus). I reproduced the failure on this tree, reviewed the diff, and reran the stdio tests before opening this.

I have not asked to be assigned. If the auto-close bot parks this until a maintainer wants an outside PR, that is understood.

stdio_client wrapped its reader/writer in anyio.create_task_group()
inside the async context manager. The task group binds its cancel
scope to the task that opened the transport, and anyio then requires
that task to close transports LIFO. Independent AsyncExitStacks,
multi-client managers, and unordered pytest fixtures close FIFO and
hit:

    RuntimeError: Attempted to exit cancel scope in a different task

On asyncio, spawn the pipe tasks with asyncio.ensure_future so they
are not stacked on the caller's cancel-scope. Shutdown order is
unchanged. Trio still uses a task group and still requires LIFO.

Regression tests pin oldest-first and newest-first teardown, and
that an unhandled pipe-task failure still surfaces.

Fixes modelcontextprotocol#577
@github-actions github-actions Bot added the missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md) label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — CONTRIBUTING.md explains why and how we work. This PR has been closed for now because you aren't currently assigned to #577.

If a maintainer would like this change as a PR from you, they'll assign you to #577 and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.)

There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten.

Maintainers: reopening this PR, removing the missing-issue-link label, or adding bypass-issue-check bypasses the check.

@github-actions github-actions Bot closed this Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RuntimeError: Attempted to exit cancel scope in a different task when cleaning up multiple MCPClient instances out-of-order

1 participant