Let stdio_server exit while the peer holds stdin open

Closed
WE
17d ago
+179-15

Fixes #3551

Problem

stdio_server() reads stdin with an uncancellable AnyIO worker-thread readline. When the transport is left while the peer still holds stdin open, it cannot shut down:

  • SIGINT (KeyboardInterrupt) and a client that closes the server's stdout (the writer raises BrokenPipeError) both cancel the task group, which then waits forever for the parked read.
  • Returning from the async with stdio_server() block doesn't cancel the reader at all, so it waits for the next line or EOF.
  • Even when the read is abandoned, AnyIO's worker threads are non-daemon, so threading._shutdown still blocks interpreter exit. On the in-place path, where the transport reads sys.stdin.buffer itself (the v1.x behaviour, and main's fallback when fd isolation fails), the parked read also holds the BufferedReader lock. Finalization then aborts with Fatal Python error: _enter_buffered_busy ... <stdin> (SIGABRT). This is reproducible on Python 3.13 and 3.14.

Downstream report: weijie-tan3/trino-mcp#60 (Copilot CLI recycling stdio servers produced a SIGABRT crash report on every shutdown). The consumer-side workaround, weijie-tan3/trino-mcp#61, uses os._exit. This PR fixes it in the transport instead.

Reproduction (main, Python 3.14, macOS)

I spawned MCPServer(...).run("stdio"), sent initialize and notifications/initialized, and then:

scenariobeforeafter
close stdin (EOF)exit 0exit 0
SIGINT, stdin kept openhangsexits (KeyboardInterrupt)
client closes stdout, stdin kept openhangsexits (BrokenPipeError, rc 1)
in-place fallback path + SIGINT (fix without the raw-read change)SIGABRT _enter_buffered_busyexits

Fix (src/mcp/server/stdio.py)

  1. Each readline runs in a daemon thread that fills a concurrent.futures.Future. The task awaits future.result with abandon_on_cancel=True and cancels the future on the way out, which also releases the AnyIO worker. If a read is still parked, it no longer blocks cancellation or interpreter exit. The claim code already expects this: the private fd is opened closefd=False because "a worker thread can still block on this descriptor after the transport exits".
  2. stdin is read through its raw layer when it has one, so the parked read holds no BufferedReader lock and finalization can't hit _enter_buffered_busy.
  3. The reader is cancelled when the caller leaves the context. The writer's contract is unchanged: it still drains until write_stream is closed.

Trade-off: once the transport exits, a still-parked daemon read may consume one more line. The old code had the same loss, since exiting blocked until it read and discarded that line. Starting a thread per line adds a small cost, which is negligible at stdio JSON-RPC rates.

Tests

  • tests/server/test_stdio.py::test_stdio_server_exits_while_the_peer_holds_stdin_open[returns|is_cancelled] (in-process, planted fd-0 pipe). A watchdog makes a regression fail with TimeoutError instead of hanging.
  • tests/server/test_stdio.py::test_stdio_server_surfaces_a_failing_stdin_read: stdin read errors still propagate.
  • tests/transports/stdio/test_lifecycle.py::test_a_server_that_stops_serving_while_stdin_stays_open_exits_cleanly[isolated|in_place] (real subprocess): the server exits 0 on its own, with no Fatal Python error.

The four new regression tests fail on main (three hang until they time out, and the subprocess tests are terminated) and pass with this change.

Validation

  • ./scripts/test (full suite, 100% coverage, strict-no-cover): passes on 3.14
  • stdio suites (tests/server/test_stdio.py, tests/transports/stdio/, tests/client/test_stdio.py, tests/interaction/transports/test_stdio.py) pass on 3.10, 3.12, and 3.14
  • ruff format, ruff check, and pyright: clean

The two existing except anyio.ClosedResourceError pragmas changed from no cover to lax no cover. Cancellation now passes through those except lines (they are evaluated), but their bodies still never run.

Verified against #3551's pty reproduction on macOS (Python 3.14.5), which that issue had not been tested on: before, the server needs three SIGINTs and ends with the _thread._shutdown traceback; after, one SIGINT exits.

AI disclosure: an AI coding agent drafted this change and the tests. I reproduced the bug, reviewed the change, and can explain it.

Note: I haven't filed an upstream issue for this yet. I'm happy to open one and link it, following CONTRIBUTING.