Let stdio_server exit while the peer holds stdin open
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 raisesBrokenPipeError) 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._shutdownstill blocks interpreter exit. On the in-place path, where the transport readssys.stdin.bufferitself (the v1.x behaviour, and main's fallback when fd isolation fails), the parked read also holds theBufferedReaderlock. Finalization then aborts withFatal 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:
| scenario | before | after |
|---|---|---|
| close stdin (EOF) | exit 0 | exit 0 |
| SIGINT, stdin kept open | hangs | exits (KeyboardInterrupt) |
| client closes stdout, stdin kept open | hangs | exits (BrokenPipeError, rc 1) |
| in-place fallback path + SIGINT (fix without the raw-read change) | SIGABRT _enter_buffered_busy | exits |
Fix (src/mcp/server/stdio.py)
- Each
readlineruns in a daemon thread that fills aconcurrent.futures.Future. The task awaitsfuture.resultwithabandon_on_cancel=Trueand 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 openedclosefd=Falsebecause "a worker thread can still block on this descriptor after the transport exits". - stdin is read through its raw layer when it has one, so the parked read holds no
BufferedReaderlock and finalization can't hit_enter_buffered_busy. - The reader is cancelled when the caller leaves the context. The writer's contract is unchanged: it still drains until
write_streamis 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 withTimeoutErrorinstead 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 noFatal 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, andpyright: 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.