Repository navigation
stdio server ignores the first two Ctrl+C presses at a terminal (blocked stdin worker thread) #3551
Description
Activity
- addedv2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)v1Affects the v1.x maintenance lineAffects the v1.x maintenance line
on Sep 20, 2026 I investigated this on
mainand verified the traceback whenMCPServer.run("stdio")is terminated with Ctrl+C.The worker thread reading
sys.stdinvia AnyIO stays blocked while AnyIO attempts to unwind the event loop, causing unhandledKeyboardInterrupttracebacks during interpreter shutdown.I've tested wrapping
anyio.run(self.run_stdio_async)inMCPServer.runwith atry ... except (KeyboardInterrupt, SystemExit): passblock in PR #3561, which allows clean shutdown when testing servers interactively. Happy to be assigned to this issue so the PR can be reviewed!Two additions from hitting this on macOS, which the report notes wasn't tested.
The pty reproduction behaves the same on macOS (Python 3.14.5, arm64,
main@f1b65890): three SIGINTs, ending in the_thread._shutdowntraceback.The same blocked read has two further consequences beyond the TTY case, both reachable by a normal client rather than a person at a terminal:
-
A client that closes the server's stdout while keeping stdin open hangs the same way. The writer raises
BrokenPipeError, the task group cancels, and cancellation waits on the parked read. Returning normally fromasync with stdio_server()hangs too, since the reader isn't cancelled at all. -
On the in-place path, where the transport reads
sys.stdin.bufferitself (v1.x always;mainwhen fd isolation fails), the parked read holds theBufferedReaderlock, so finalization aborts rather than hanging:Fatal Python error: _enter_buffered_busy: could not acquire lock for <_io.BufferedReader name='<stdin>'> at interpreter shutdown, possibly due to daemon threadsThat reproduces on 3.13 and 3.14. A host recycling stdio servers produced an OS crash report on every shutdown: stdio server aborts (SIGABRT) at shutdown: _enter_buffered_busy on stdin with parked reader thread weijie-tan3/trino-mcp#60.
I filed #3596 before finding this issue and have closed it as a duplicate.
I took the third option listed here (daemon thread,
abandon_on_cancel=True), plus reading stdin through its raw layer so the parked read holds no buffer lock, and cancelling the reader when the caller leaves the context. On the pty reproduction that turns three SIGINTs into one, with no_thread._shutdowntraceback. It's in #3595 with regression tests, if it's useful as a starting point — no need to assign it to me, and I'm happy for a maintainer to take the approach and write their own.-
- marked stdio_server hangs at shutdown when the client keeps stdin open #3596 as a duplicate of this issue
on Sep 29, 2026 I can reproduce the hang and have traced it to the stdin reader task blocking the task-group exit path.
Reproduction
Run this script against current
main. It plants a pipe on fd 0, keeps the write end open (so the server never sees EOF), and exits thestdio_server()context immediately:import os import sys from io import TextIOWrapper import anyio from mcp.server.stdio import stdio_server def main() -> None: real_dup2 = os.dup2 in_r, in_w = os.pipe() saved0 = os.dup(0) os.dup2(in_r, 0) stdin_double = TextIOWrapper(open(0, "rb", closefd=False), encoding="utf-8") try: sys.stdin = stdin_double async def run() -> None: print("entering stdio_server context...", flush=True) try: with anyio.fail_after(5): async with stdio_server() as (read_stream, write_stream): print("inside context; exiting immediately...", flush=True) read_stream.close() await write_stream.aclose() except TimeoutError: print("HANG CONFIRMED: stdio_server() did not exit within 5 s", flush=True) return print("exited stdio_server context cleanly", flush=True) anyio.run(run) finally: sys.stdin = sys.__stdin__ stdin_double.close() real_dup2(saved0, 0) os.close(saved0) os.close(in_r) os.close(in_w) if __name__ == "__main__": main()
Observed output (unmodified
main):entering stdio_server context... inside context; exiting immediately... HANG CONFIRMED: stdio_server() did not exit within 5 sThe context manager never returns, so fd 0/1 are never restored.
Mechanism
The blocking path is in
src/mcp/server/stdio.py:stdin_reader()loops withasync for line in stdin:at line 187.stdinisanyio.wrap_file(_UnownedTextWrapper(...)), so each iteration dispatchesTextIOWrapper.readline()to an anyio worker thread.- When the client keeps the underlying pipe open, that worker thread blocks forever waiting for EOF.
stdio_server()yields insideasync with anyio.create_task_group() as tg:(line 210). Task-group__aexit__does not return until every spawned task finishes, so it waits on the blocked reader.- Because the task group cannot exit, the outer
finallyblock (lines 213-217) that callsrestore_stdout()/restore_stdin()is never reached.
In short: the shutdown path is serialized behind a reader that has no clean way to be unblocked when stdin stays open. And for the in-place fallback (
_claim_fdreturningstream.buffer), the parked read also holdssys.stdin.buffer'sBufferedReaderlock, which is the interpreter-shutdown crash seen on 3.13/3.14.Fix direction
The reader needs to observe EOF before the task group waits. A minimal approach I verified locally:
- Keep a reference to the binary buffer returned by
_claim_fd(). - Wrap the
yieldin atry/finallyinside the task group. - In that inner
finally:- Call
restore_stdin()first so fd 0 is back on the original wire. os.close(stdin_buffer.fileno())to force the worker thread'sreadline()to return EOF.
- Call
- Widen
stdin_reader()'s exception handler to treatValueError/OSErrorfrom a closed stdin as a clean shutdown.
With that change the reproduction script prints
exited stdio_server context cleanlyand the existingtests/server/test_stdio.pysuite still passes (17 passed).I haven't opened a PR because this repo asks for issue assignment first — happy to open one if the maintainers want me to.
—
Affiliation: thyn-ai / Algenta
Description
Running an
MCPServerover stdio from a terminal, Ctrl+C does nothing the first time, nothing visible the second time, and the third ends the process with a traceback out ofthreading._shutdown. This is related to #2663 but is not the same problem, and the fix proposed there (catchingKeyboardInterruptaroundanyio.run, #2745) does not address it: on a TTY the first Ctrl+C never raisesKeyboardInterrupt.A client that closes stdin never sees this, because EOF ends the read. It only affects people running a server by hand, which is what you do when testing one.
Reproduction
server.py:Run
python server.pyin a terminal and press Ctrl+C three times. Or, scripted, with a pty as stdin:Output:
Cause
stdio_server()reads stdin throughanyio.wrap_file, so eachreadline()runs in an AnyIO worker thread. A thread blocked inreadline()on a terminal cannot be cancelled.stdin_readeris awaiting the worker thread. Afaulthandlerdump taken at this point shows the main thread still inrun_forever→select, and theAnyIO worker threadinside the blocking call.KeyboardInterruptout of the loop. Interpreter shutdown then joins the same worker thread, which is still blocked._thread._shutdowntraceback.The comment in
_claim_fd("a worker thread can still block on this descriptor after the transport exits") suggests this is known for the descriptor's lifetime; this is the same thread seen from the user's side.Possible fixes
MCPServer.run("stdio"), install a SIGINT handler for the duration of the run that ends the process without waiting for the reader thread. We useos._exit(130)in our server, which is only appropriate if nothing needs unwinding, so probably not as a library default.abandon_on_cancel=Truein a daemon thread, so neither task cancellation nor interpreter shutdown waits on it.Environment
MCP Python SDK 2.2.0, anyio 4.15.1, Python 3.14.7, Linux (Fedora 44). Not tested on v1.x, macOS or Windows.