Skip to content

stdio_server cannot be cancelled, and the process cannot exit, while the client still holds stdin open #3808

Open

Description

@gericke98

Description

mcp.server.stdio.stdio_server() reads stdin through anyio.wrap_file(...). Each readline() runs in an anyio worker
thread with the default abandon_on_cancel=False, i.e. shielded from cancellation. While the client keeps stdin open,
cancelling the transport never returns: the task group inside stdio_server waits for a readline() that only ends on
the client's next line or on EOF.

This matters for a server that has to stop on its own while the client is still connected: a background task of the
server fails (the task group cancels its siblings, including the transport), or the process receives a signal and cancels
its task group. Instead of exiting, the process stays alive until the client happens to write a line or closes the pipe.

Making the read abandonable (abandon_on_cancel=True) lets the transport exit, but not the process: anyio's worker
threads are not daemon threads, so the interpreter joins the thread still blocked in readline() at exit and hangs
there (verified: the task group exits after 0.5 s, "end of script" is printed, then the interpreter waits forever).

Reproduction

import sys, time
import anyio
from mcp.server.stdio import stdio_server

async def main():
    t0 = time.monotonic()
    async with anyio.create_task_group() as tg:
        async def serve():
            async with stdio_server() as (read_stream, write_stream):
                async with read_stream, write_stream:
                    await anyio.sleep_forever()
        tg.start_soon(serve)
        await anyio.sleep(0.5)
        tg.cancel_scope.cancel()
    print(f"exited after {time.monotonic() - t0:.2f} s", file=sys.stderr)

anyio.run(main)
  • python repro.py </dev/null (stdin at EOF): prints exited after 0.50 s and the process ends.
  • sleep 30 | python repro.py (stdin held open, nothing written): nothing is printed and the process does not end until
    the pipe closes (killed by timeout 10 in my runs).

Expected behavior

Cancelling stdio_server returns, and the process can exit, without the client's cooperation.

Environment

  • mcp 2.3.0 (also main at d7845c0: src/mcp/server/stdio.py reads stdin the same way)
  • anyio 4.15.1, Python 3.12.12 and 3.13, macOS 15 (arm64) and Linux x86_64

Proposed fix (I would like to submit the pull request)

On POSIX, when the private duplicate that _claim_fd already creates is a pipe or a socket, read it on the event loop
instead of in a thread: put it in non-blocking mode (its own descriptor, so children inheriting the standard streams see
no change; the original mode is restored on exit), anyio.wait_readable then os.read, decoded with the standard
incremental UTF-8 decoder (errors="replace") and io.IncrementalNewlineDecoder, so framing and universal newlines stay
exactly as the text wrapper produced them. Cancellation then interrupts the wait and no thread is left behind, so the
interpreter can exit. Regular files and the null device cannot be waited on under Linux epoll, Windows pipes cannot be
polled, and injected or in-place streams have no private descriptor: all of those keep today's worker-thread readline
unchanged, and the docs say so. Tests: cancellation while a line is being delivered with the pipe held open (blocking mode
restored, also for an initially non-blocking descriptor), a POSIX subprocess that cancels itself with stdin held open and
must exit 0 with fd 0 back on the wire, the reader's framing cases (a line and a multibyte character split across reads,
CRLF, bare CR, an invalid byte, a partial line at EOF), and stdin redirected from a regular file and from /dev/null. The
patch is ready on my fork and passes ruff, pyright, the full test suite with coverage, and the reproduction above.

Branch with the fix and its tests: https://github.com/gericke98/python-sdk/tree/stdio-cancel-abandons-stdin-read (commit bdecbfb, on main d7845c0; full suite, coverage and strict-no-cover green locally).

I used an AI coding assistant to investigate and draft the fix; I have read and run the result and can explain it.

Activity

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Nov 3, 2026
  2. media-ctrl commented on Nov 4, 2026

    @media-ctrl

    Both halves of this reproduce, and the second one is a threading rule rather than an anyio bug.

    I checked the transport source: stdio.py:47 wraps sys.stdin.buffer with anyio.wrap_file, and stdin_reader() iterates it with async for line in stdin. Each readline() therefore runs in an anyio worker thread. With stdin held open by a pipe and a cancel_scope.cancel() after 0.4s, my probe never reached the line after the task group: it hung until the 5s timeout killed it. Same script with stdin closed exits immediately, so the difference is entirely the blocked read and not the task group.

    The exit hang has the same root cause you identified. anyio worker threads are non-daemon, and threading._shutdown joins them after the interpreter has otherwise finished, so "task group exited" and "process exited" are separate events. abandon_on_cancel=True lets the task group finish and leaves a thread the interpreter still waits for. That matches your measurement exactly.

    The variant that does exit on both counts: read stdin on a threading.Thread(daemon=True) and hand lines to an anyio stream. Measured with the same probe, stdin still open: cancellation returned at 0.40s and the process exited normally, because daemon threads are not joined at shutdown. Control with stdin closed behaves identically. A daemon thread blocked in read() is reclaimed by the interpreter rather than by the cooperative machinery, which is the one thing you actually want at shutdown.

    Two caveats before using that shape in the SDK. A daemon reader can be killed mid-line, so on shutdown drain the write side first and let the reader die last. And the blocked thread still holds fd 0, so any parent that waits on the pipe will not see EOF from your side; if the server is expected to release stdin, the clean version is to have the reader poll a non-blocking fd and exit on a stop flag, with the daemon thread as the fallback for platforms where that is awkward.

    I run long-lived stdio servers under clients that keep the pipe open for hours, and shutdown correctness is the part that breaks first in that setup, so an SDK-level fix here is worth more than it looks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions