Skip to content

Commit 0429f67

Browse files
weijie-tan3Copilot
andcommitted
Let stdio_server exit while the peer holds stdin open
The stdin reader ran each readline as an uncancellable AnyIO worker-thread call, so leaving stdio_server() -- by returning, by SIGINT, or after a broken stdout -- waited for a line or EOF that a peer keeping stdin open never sends. Even once abandoned, a non-daemon worker blocks interpreter exit, and a read parked in sys.stdin's BufferedReader makes finalization abort with `_enter_buffered_busy`. Read each line in a daemon thread awaited through an abandonable future, read stdin through its raw (lock-free) layer, and cancel the reader when the caller leaves the context. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f1b6589 commit 0429f67

3 files changed

Lines changed: 179 additions & 15 deletions

File tree

‎src/mcp/server/stdio.py‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,20 @@ async def run_server():
1111
```
1212
"""
1313

14+
import io
1415
import os
1516
import sys
1617
import threading
1718
from collections.abc import Callable
19+
from concurrent.futures import Future, InvalidStateError
1820
from contextlib import asynccontextmanager, suppress
1921
from dataclasses import dataclass
2022
from io import TextIOWrapper
21-
from typing import BinaryIO, Literal, TextIO
23+
from typing import IO, BinaryIO, Literal, TextIO, cast
2224

2325
import anyio
2426
import anyio.lowlevel
27+
import anyio.to_thread
2528
import mcp_types as types
2629

2730
from mcp.os.win32.utilities import rebind_std_handle_to_fd
@@ -158,6 +161,44 @@ def release() -> None:
158161
return os.fdopen(private_fd, mode, closefd=False), release
159162

160163

164+
def _lock_free_reader(buffer: BinaryIO) -> BinaryIO:
165+
"""The raw layer beneath a buffered stdin, when it has one.
166+
167+
A reader thread can still be parked in a read at interpreter exit. Through a
168+
BufferedReader that read holds the buffer's lock, and finalization aborts with
169+
`_enter_buffered_busy` when it closes sys.stdin; the raw layer takes no lock.
170+
"""
171+
raw = getattr(buffer, "raw", None)
172+
# TextIOWrapper reads a raw stream through read(), which RawIOBase provides.
173+
return cast(BinaryIO, raw) if isinstance(raw, io.RawIOBase) else buffer
174+
175+
176+
def _readline_into(file: IO[str], future: "Future[str]") -> None:
177+
try:
178+
line = file.readline()
179+
except Exception as exc: # thread entry point: every failure is handed to the awaiting task
180+
with suppress(InvalidStateError):
181+
future.set_exception(exc)
182+
else:
183+
with suppress(InvalidStateError):
184+
future.set_result(line)
185+
186+
187+
async def _readline(file: IO[str]) -> str:
188+
"""Read one line without letting a blocked read outlive cancellation or hold up exit.
189+
190+
The read runs in a daemon thread, so neither cancellation nor interpreter exit
191+
waits for input that may never arrive (a peer that keeps stdin open, SIGINT, or a
192+
closed stdout). Cancelling the future releases the AnyIO worker waiting on it.
193+
"""
194+
future: Future[str] = Future()
195+
threading.Thread(target=_readline_into, args=(file, future), name="mcp-stdio-reader", daemon=True).start()
196+
try:
197+
return await anyio.to_thread.run_sync(future.result, abandon_on_cancel=True)
198+
finally:
199+
future.cancel()
200+
201+
161202
@asynccontextmanager
162203
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
163204
"""Serve MCP over the process's stdin and stdout.
@@ -173,27 +214,32 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
173214
try:
174215
if not stdin:
175216
stdin_buffer, restore_stdin = _claim_fd(0, sys.stdin, "rb", _open_stdin_diversion)
176-
stdin = anyio.wrap_file(_UnownedTextWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
217+
stdin = anyio.wrap_file(
218+
_UnownedTextWrapper(_lock_free_reader(stdin_buffer), encoding="utf-8", errors="replace")
219+
)
177220
if not stdout:
178221
stdout_buffer, restore_stdout = _claim_fd(1, sys.stdout, "wb", _open_stdout_diversion)
179222
stdout = anyio.wrap_file(_UnownedTextWrapper(stdout_buffer, encoding="utf-8"))
180223

181224
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
182225
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
183226

227+
stdin_reader_scope = anyio.CancelScope()
228+
184229
async def stdin_reader():
185230
try:
186231
async with read_stream_writer:
187-
async for line in stdin:
188-
try:
189-
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
190-
except Exception as exc:
191-
await read_stream_writer.send(exc)
192-
continue
193-
194-
session_message = SessionMessage(message)
195-
await read_stream_writer.send(session_message)
196-
except anyio.ClosedResourceError: # pragma: no cover
232+
with stdin_reader_scope:
233+
while line := await _readline(stdin.wrapped):
234+
try:
235+
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
236+
except Exception as exc:
237+
await read_stream_writer.send(exc)
238+
continue
239+
240+
session_message = SessionMessage(message)
241+
await read_stream_writer.send(session_message)
242+
except anyio.ClosedResourceError: # pragma: lax no cover
197243
await anyio.lowlevel.checkpoint()
198244

199245
async def stdout_writer():
@@ -203,13 +249,17 @@ async def stdout_writer():
203249
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
204250
await stdout.write(json + "\n")
205251
await stdout.flush()
206-
except anyio.ClosedResourceError: # pragma: no cover
252+
except anyio.ClosedResourceError: # pragma: lax no cover
207253
await anyio.lowlevel.checkpoint()
208254

209255
async with anyio.create_task_group() as tg:
210256
tg.start_soon(stdin_reader)
211257
tg.start_soon(stdout_writer)
212-
yield read_stream, write_stream
258+
try:
259+
yield read_stream, write_stream
260+
finally:
261+
# The caller is done; a peer holding stdin open must not keep the transport alive.
262+
stdin_reader_scope.cancel()
213263
finally:
214264
if restore_stdout is not None:
215265
restore_stdout()

‎tests/server/test_stdio.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from io import TextIOWrapper
99

1010
import anyio
11+
import anyio.lowlevel
1112
import anyio.to_thread
1213
import pytest
1314
from mcp_types import (
@@ -315,6 +316,63 @@ def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
315316
pytest.fail("unreachable") # pragma: no cover
316317

317318

319+
@contextmanager
320+
def _unblock_a_hung_stdin_read_after(seconds: float, write_fd: int) -> Iterator[None]:
321+
"""Writes a stray line after `seconds`, so an uncancellable stdin read fails the test instead of hanging it."""
322+
watchdog = threading.Timer(seconds, os.write, (write_fd, b"\n"))
323+
watchdog.start()
324+
try:
325+
yield
326+
finally:
327+
watchdog.cancel()
328+
329+
330+
@pytest.mark.anyio
331+
@pytest.mark.parametrize("leaving", ["returns", "is_cancelled"])
332+
async def test_stdio_server_exits_while_the_peer_holds_stdin_open(
333+
leaving: str, monkeypatch: pytest.MonkeyPatch
334+
) -> None:
335+
"""Leaving the transport does not wait for a stdin line or EOF that may never come.
336+
337+
Regression: the stdin read was an uncancellable worker-thread call, so returning
338+
from the context, a closed stdout, or SIGINT hung until the peer sent more input.
339+
"""
340+
with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
341+
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
342+
try:
343+
with _unblock_a_hung_stdin_read_after(10, in_w), anyio.fail_after(5):
344+
with anyio.CancelScope() as scope:
345+
async with stdio_server() as (read_stream, write_stream):
346+
await anyio.wait_all_tasks_blocked() # the reader is now parked on stdin
347+
read_stream.close()
348+
await write_stream.aclose()
349+
if leaving == "is_cancelled":
350+
scope.cancel()
351+
await anyio.sleep_forever()
352+
assert scope.cancelled_caught == (leaving == "is_cancelled")
353+
# A hang that outlived the deadline surfaces here as TimeoutError.
354+
await anyio.lowlevel.checkpoint()
355+
finally:
356+
os.close(in_w)
357+
358+
359+
@pytest.mark.anyio
360+
async def test_stdio_server_surfaces_a_failing_stdin_read() -> None:
361+
"""An error reading stdin propagates out of the transport rather than being swallowed."""
362+
363+
class _FailingStdin(io.StringIO):
364+
def readline(self, size: int | None = -1, /) -> str:
365+
raise OSError("stdin read failed")
366+
367+
stdin = anyio.AsyncFile(_FailingStdin())
368+
stdout = anyio.AsyncFile(io.StringIO())
369+
with anyio.fail_after(5), pytest.RaisesGroup(pytest.RaisesExc(OSError, match="stdin read failed")):
370+
# Coverage mis-traces nested `async with` exit arcs on Python 3.11+.
371+
async with stdio_server(stdin=stdin, stdout=stdout) as (read_stream, write_stream): # pragma: no branch
372+
async with read_stream, write_stream: # pragma: no branch
373+
await anyio.sleep_forever()
374+
375+
318376
@pytest.mark.anyio
319377
async def test_stdio_server_takes_stdout_off_the_descriptor_table_while_serving(
320378
monkeypatch: pytest.MonkeyPatch,

‎tests/transports/stdio/test_lifecycle.py‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
import anyio
1212
import anyio.abc
1313
import pytest
14-
from mcp_types import TextContent
14+
from mcp_types import JSONRPCRequest, TextContent
1515

1616
from mcp.client import stdio
1717
from mcp.client.client import Client
1818
from mcp.client.stdio import StdioServerParameters, stdio_client
1919
from mcp.os.win32.utilities import FallbackProcess
20+
from mcp.shared.message import SessionMessage
2021
from tests.transports.stdio._liveness import (
2122
accept_alive,
2223
assert_stream_closed,
@@ -258,3 +259,58 @@ def run_noisy_child() -> str:
258259
assert isinstance(content, TextContent)
259260
assert content.text == "0"
260261
assert "this is not json" in server_stderr
262+
263+
264+
@pytest.mark.anyio
265+
@pytest.mark.parametrize("stdin_claim", ["isolated", "in_place"])
266+
async def test_a_server_that_stops_serving_while_stdin_stays_open_exits_cleanly(
267+
stdin_claim: str,
268+
tmp_path: Path,
269+
spawned_processes: list[anyio.abc.Process | FallbackProcess],
270+
terminate_calls: list[anyio.abc.Process | FallbackProcess],
271+
) -> None:
272+
"""Leaving stdio_server() while the client holds stdin open lets the interpreter exit.
273+
274+
The stdin read still parked at exit must neither block shutdown (a hang until the
275+
client closes stdin) nor hold sys.stdin's buffer lock (a `_enter_buffered_busy`
276+
abort during finalization). `in_place` forces the degraded path that reads sys.stdin.
277+
"""
278+
degrade = dedent(
279+
"""
280+
import mcp.server.stdio
281+
282+
def failing_dup_above_std(fd):
283+
raise OSError("forced degrade")
284+
285+
mcp.server.stdio._dup_above_std = failing_dup_above_std
286+
"""
287+
)
288+
server = (degrade if stdin_claim == "in_place" else "") + dedent(
289+
"""
290+
import anyio
291+
from mcp.server.stdio import stdio_server
292+
293+
async def main():
294+
async with stdio_server() as (read_stream, write_stream):
295+
await read_stream.receive()
296+
await write_stream.aclose()
297+
298+
anyio.run(main)
299+
"""
300+
)
301+
params = StdioServerParameters(command=sys.executable, args=["-c", server])
302+
303+
with (tmp_path / "server-stderr.txt").open("w+", encoding="utf-8") as errlog:
304+
# Allow one cold interpreter start on loaded CI.
305+
with anyio.fail_after(10.0):
306+
async with stdio_client(params, errlog=errlog) as (read_stream, write_stream):
307+
await write_stream.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
308+
# Ends at the server's stdout EOF, i.e. once its process has exited.
309+
async for _ in read_stream:
310+
pass # pragma: no cover
311+
errlog.seek(0)
312+
server_stderr = errlog.read()
313+
314+
assert "Fatal Python error" not in server_stderr
315+
assert spawned_processes[0].returncode == 0
316+
assert terminate_calls == []

0 commit comments

Comments
 (0)