Repository navigation
stdio server ignores the first two Ctrl+C presses at a terminal (blocked stdin worker thread) #3551
Description
Description
Running an MCPServer over 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 of threading._shutdown. This is related to #2663 but is not the same problem, and the fix proposed there (catching KeyboardInterrupt around anyio.run, #2745) does not address it: on a TTY the first Ctrl+C never raises KeyboardInterrupt.
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:
from mcp.server.mcpserver import MCPServer
MCPServer("repro").run("stdio")Run python server.py in a terminal and press Ctrl+C three times. Or, scripted, with a pty as stdin:
import pty, signal, subprocess, sys, time
master, slave = pty.openpty()
proc = subprocess.Popen([sys.executable, "server.py"], stdin=slave, stderr=subprocess.PIPE)
time.sleep(2) # let it reach the stdin read
for n in range(1, 6):
proc.send_signal(signal.SIGINT)
try:
proc.wait(1)
break
except subprocess.TimeoutExpired:
print(f"still running 1s after SIGINT #{n}")
print(f"exit code {proc.returncode} after {n} SIGINT(s)")
print(proc.stderr.read().decode()[-400:])Output:
still running 1s after SIGINT #1
still running 1s after SIGINT #2
exit code -2 after 3 SIGINT(s)
...
Exception ignored while joining a thread in _thread._shutdown():
Traceback (most recent call last):
File "/usr/lib64/python3.14/threading.py", line 1583, in _shutdown
_thread_shutdown()
KeyboardInterrupt:
Cause
stdio_server() reads stdin through anyio.wrap_file, so each readline() runs in an AnyIO worker thread. A thread blocked in readline() on a terminal cannot be cancelled.
- First SIGINT: asyncio's handler cancels the main task. The task cannot finish cancelling, because
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. - Second SIGINT: asyncio raises
KeyboardInterruptout of the loop. Interpreter shutdown then joins the same worker thread, which is still blocked. - Third SIGINT: interrupts that join, producing the
_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
- In
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. - Read stdin without a thread where the platform allows it (on POSIX, wait for fd readability on the event loop and read non-blocking), so cancellation can actually complete.
- Run the blocking read with
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.
Activity
HirthikBalaji commentedon Sep 22, 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!weijie-tan3 commentedon Sep 29, 2026
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:That 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.0xamlab commentedon 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:Observed output (unmodified
main):The 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.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.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:
_claim_fd().yieldin atry/finallyinside the task group.finally: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.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