Skip to content

stdio server ignores the first two Ctrl+C presses at a terminal (blocked stdin worker thread) #3551

Description

@pwesselius

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.

  1. First SIGINT: asyncio's handler cancels the main task. The task cannot finish cancelling, because stdin_reader is awaiting the worker thread. A faulthandler dump taken at this point shows the main thread still in run_forever → select, and the AnyIO worker thread inside the blocking call.
  2. Second SIGINT: asyncio raises KeyboardInterrupt out of the loop. Interpreter shutdown then joins the same worker thread, which is still blocked.
  3. Third SIGINT: interrupts that join, producing the _thread._shutdown traceback.

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 use os._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=True in 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

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Sep 20, 2026
  2. HirthikBalaji commented on Sep 22, 2026

    @HirthikBalaji

    I investigated this on main and verified the traceback when MCPServer.run("stdio") is terminated with Ctrl+C.

    The worker thread reading sys.stdin via AnyIO stays blocked while AnyIO attempts to unwind the event loop, causing unhandled KeyboardInterrupt tracebacks during interpreter shutdown.

    I've tested wrapping anyio.run(self.run_stdio_async) in MCPServer.run with a try ... except (KeyboardInterrupt, SystemExit): pass block in PR #3561, which allows clean shutdown when testing servers interactively. Happy to be assigned to this issue so the PR can be reviewed!

  3. weijie-tan3 commented on Sep 29, 2026

    @weijie-tan3

    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._shutdown traceback.

    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 from async with stdio_server() hangs too, since the reader isn't cancelled at all.

    • On the in-place path, where the transport reads sys.stdin.buffer itself (v1.x always; main when fd isolation fails), the parked read holds the BufferedReader lock, 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 threads
      

      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._shutdown traceback. 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.

  4. 0xamlab commented on Sep 29, 2026

    @0xamlab

    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 the stdio_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 s
    

    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 with async for line in stdin: at line 187. stdin is anyio.wrap_file(_UnownedTextWrapper(...)), so each iteration dispatches TextIOWrapper.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 inside async 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 finally block (lines 213-217) that calls restore_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_fd returning stream.buffer), the parked read also holds sys.stdin.buffer's BufferedReader lock, 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:

    1. Keep a reference to the binary buffer returned by _claim_fd().
    2. Wrap the yield in a try/finally inside the task group.
    3. 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's readline() to return EOF.
    4. Widen stdin_reader()'s exception handler to treat ValueError / OSError from a closed stdin as a clean shutdown.

    With that change the reproduction script prints exited stdio_server context cleanly and the existing tests/server/test_stdio.py suite 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

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