Fix stdio client shutdown bugs and rebuild the stdio test suite (#2773)

This commit is contained in:
Max 2026-06-05 16:15:43 +01:00 • committed by GitHub
commit bdc48e98b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2645 additions and 1130 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,13 @@
"""The stdio transport: one subprocess end-to-end test and one in-process framing test.
Everything else in the suite runs in a single process; the subprocess test exists to prove the same
client↔server round trip works over the stdio transport's real boundary (a child process whose
stdin/stdout carry one newline-delimited JSON-RPC message per line). The server lives in
`_stdio_server.py` and is launched via `python -m` so subprocess coverage measurement applies.
The subprocess test proves the client-server round trip over the transport's real process
boundary; its server lives in `_stdio_server.py` and is launched via `python -m` so subprocess
coverage measurement applies. The framing test drives `stdio_server` over injected in-process
streams instead.
The framing test drives `stdio_server` in-process by passing it injected text streams instead of the
real stdin/stdout, so the raw lines the transport writes can be asserted directly without a process
boundary.
stdio is deliberately not a leg of the `connect`-fixture matrix: spawning a subprocess per test
would be slow, and the matrix already proves transport-agnosticism over three in-process
transports. Process-lifecycle edge cases (escalation to terminate/kill, parse errors) are covered by
`tests/client/test_stdio.py` and stay deferred here.
stdio is deliberately not a leg of the `connect`-fixture matrix: a subprocess per test would be
slow, and the matrix already proves transport-agnosticism in-process. Process-lifecycle edge
cases (terminate/kill escalation, parse errors) stay in `tests/client/test_stdio.py`.
"""
import io
@ -26,6 +21,7 @@ import anyio
import pytest
from inline_snapshot import snapshot
from mcp.client import stdio
from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.server.stdio import stdio_server
@ -51,10 +47,21 @@ _REPO_ROOT = Path(__file__).parents[3]
@requirement("transport:stdio")
@requirement("transport:stdio:clean-shutdown")
@requirement("transport:stdio:stderr-passthrough")
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -> None:
"""A Client connected over stdio initializes, calls a tool with arguments, receives the
server's log notification before the call returns, and the server exits when the transport
closes its stdin."""
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stdio-subprocess Client round-trips a tool call, a notification, and a clean exit.
The Client initializes, calls a tool with arguments, and receives the server's log
notification before the call returns; the server exits when the transport closes its
stdin.
"""
# After stdin closes, the child must unwind, write the clean-exit line, and let coverage's
# atexit hook persist its subprocess data file before escalation. The production 2s default
# was too tight on slow Windows runners: the child was killed mid-atexit (test stayed green)
# and the silently missing data file tripped the 100% coverage gate. Not under test.
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 10.0)
received: list[LoggingMessageNotificationParams] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
@ -66,15 +73,18 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -
command=sys.executable,
args=["-m", _stdio_server.__name__],
cwd=str(_REPO_ROOT),
# stdio_client deliberately filters the inherited environment to a safe minimum,
# which drops the variables coverage.py's subprocess support uses; pass them through
# so the server module is measured. Empty when not running under coverage.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")},
# stdio_client filters the inherited environment, dropping the variables
# coverage.py's subprocess support uses; pass them through so the server module is
# measured. PYTHONWARNINGS: the child recompiles anyio (pytest's pyc tag differs),
# and on 3.14 anyio's return-in-finally SyntaxWarning would land on the snapshot stderr.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}
| {"PYTHONWARNINGS": "ignore::SyntaxWarning"},
),
errlog=errlog,
)
with anyio.fail_after(10):
# Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (10s).
with anyio.fail_after(20):
async with Client(transport, logging_callback=collect) as client:
assert client.initialize_result.server_info.name == "stdio-echo"
result = await client.call_tool("echo", {"text": "across\nprocesses"})
@ -83,28 +93,23 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -
captured_stderr = errlog.read()
assert result == snapshot(CallToolResult(content=[TextContent(text="across\nprocesses")]))
# stdio carries one ordered server→client stream, so the same notification-before-response
# stdio carries one ordered server-to-client stream, so the same notification-before-response
# guarantee holds here as for the in-memory transport.
assert received == snapshot(
[LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")]
)
# The server writes this line only after its run loop returns, which happens when stdin closes:
# seeing it proves the process exited on its own rather than via the transport's terminate
# escalation, without a timing-based assertion. The capture itself proves stderr passthrough:
# the transport routes the child's stderr to the caller's `errlog` without consuming it.
# The server writes this line only after its run loop returns on stdin close: seeing it proves
# a self-exit, not the terminate escalation. The capture itself proves stderr passthrough.
assert captured_stderr == snapshot("stdio-echo: clean exit\n")
@requirement("transport:stdio:stream-purity")
@requirement("transport:stdio:no-embedded-newlines")
async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
"""Everything `stdio_server` writes is a valid JSON-RPC message on its own line, and nothing else.
"""Every `stdio_server` write is one valid JSON-RPC message on its own line.
The transport's stdin/stdout parameters are public, so the test injects in-process text streams
instead of the real process handles and drives the read/write streams directly: a JSON-RPC line on
stdin is parsed and delivered, and every message sent on the write stream appears as exactly one
newline-terminated line whose payload newlines are JSON-escaped. This proves the transport's own
framing; it does not guard `sys.stdout` against handler code that prints to it directly (see the
Each line is newline-terminated with payload newlines JSON-escaped. This proves the
transport's own framing; it does not guard `sys.stdout` against handler code (see the
divergence on `transport:stdio:stream-purity`).
"""
captured = io.StringIO()

View file

@ -1,240 +0,0 @@
"""Regression test for issue #1027: Ensure cleanup procedures run properly during shutdown
Issue #1027 reported that cleanup code after "yield" in lifespan was unreachable when
processes were terminated. This has been fixed by implementing the MCP spec-compliant
stdio shutdown sequence that closes stdin first, allowing graceful exit.
These tests verify the fix continues to work correctly across all platforms.
"""
import sys
import tempfile
import textwrap
from pathlib import Path
import anyio
import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import _create_platform_compatible_process, stdio_client
from tests.shared.test_win32_utils import escape_path_for_python
@pytest.mark.anyio
async def test_lifespan_cleanup_executed():
"""Regression test ensuring MCP server cleanup code runs during shutdown.
This test verifies that the fix for issue #1027 works correctly by:
1. Starting an MCP server that writes a marker file on startup
2. Shutting down the server normally via stdio_client
3. Verifying the cleanup code (after yield) executed and wrote its marker file
The fix implements proper stdin closure before termination, giving servers
time to run their cleanup handlers.
"""
# Create marker files to track server lifecycle
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
startup_marker = f.name
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
cleanup_marker = f.name
# Remove the files so we can detect when they're created
Path(startup_marker).unlink()
Path(cleanup_marker).unlink()
# Create a minimal MCP server using MCPServer that tracks lifecycle
server_code = textwrap.dedent(f"""
import asyncio
import sys
from pathlib import Path
from contextlib import asynccontextmanager
from mcp.server.mcpserver import MCPServer
STARTUP_MARKER = {escape_path_for_python(startup_marker)}
CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)}
@asynccontextmanager
async def lifespan(server):
# Write startup marker
Path(STARTUP_MARKER).write_text("started")
try:
yield {{"started": True}}
finally:
# This cleanup code now runs properly during shutdown
Path(CLEANUP_MARKER).write_text("cleaned up")
mcp = MCPServer("test-server", lifespan=lifespan)
@mcp.tool()
def echo(text: str) -> str:
return text
if __name__ == "__main__":
mcp.run()
""")
# Write the server script to a temporary file
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
server_script = f.name
f.write(server_code)
try:
# Launch the MCP server
params = StdioServerParameters(command=sys.executable, args=[server_script])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
# Initialize the session
result = await session.initialize()
assert result.protocol_version in ["2024-11-05", "2025-06-18", "2025-11-25"]
# Verify startup marker was created
assert Path(startup_marker).exists(), "Server startup marker not created"
assert Path(startup_marker).read_text() == "started"
# Make a test request to ensure server is working
response = await session.call_tool("echo", {"text": "hello"})
assert response.content[0].type == "text"
assert getattr(response.content[0], "text") == "hello"
# Session will be closed when exiting the context manager
# Give server a moment to complete cleanup
with anyio.move_on_after(5.0):
while not Path(cleanup_marker).exists(): # pragma: lax no cover
await anyio.sleep(0.1)
# Verify cleanup marker was created - this works now that stdio_client
# properly closes stdin before termination, allowing graceful shutdown
assert Path(cleanup_marker).exists(), "Server cleanup marker not created - regression in issue #1027 fix"
assert Path(cleanup_marker).read_text() == "cleaned up"
finally:
# Clean up files
for path in [server_script, startup_marker, cleanup_marker]:
try: # pragma: lax no cover
Path(path).unlink()
except FileNotFoundError: # pragma: lax no cover
pass
@pytest.mark.anyio
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
async def test_stdin_close_triggers_cleanup():
"""Regression test verifying the stdin-based graceful shutdown mechanism.
This test ensures the core fix for issue #1027 continues to work by:
1. Manually managing a server process
2. Closing stdin to trigger graceful shutdown
3. Verifying cleanup handlers run before the process exits
This mimics the behavior now implemented in stdio_client's shutdown sequence.
Note on Windows ResourceWarning:
On Windows, we may see ResourceWarning about unclosed file descriptors.
This is expected behavior because:
- We're manually managing the process lifecycle
- Windows file handle cleanup works differently than Unix
- The warning doesn't indicate a real issue - cleanup still works
We filter this warning on Windows only to avoid test noise.
"""
# Create marker files to track server lifecycle
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
startup_marker = f.name
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
cleanup_marker = f.name
# Remove the files so we can detect when they're created
Path(startup_marker).unlink()
Path(cleanup_marker).unlink()
# Create an MCP server that handles stdin closure gracefully
server_code = textwrap.dedent(f"""
import asyncio
import sys
from pathlib import Path
from contextlib import asynccontextmanager
from mcp.server.mcpserver import MCPServer
STARTUP_MARKER = {escape_path_for_python(startup_marker)}
CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)}
@asynccontextmanager
async def lifespan(server):
# Write startup marker
Path(STARTUP_MARKER).write_text("started")
try:
yield {{"started": True}}
finally:
# This cleanup code runs when stdin closes, enabling graceful shutdown
Path(CLEANUP_MARKER).write_text("cleaned up")
mcp = MCPServer("test-server", lifespan=lifespan)
@mcp.tool()
def echo(text: str) -> str:
return text
if __name__ == "__main__":
# The server should exit gracefully when stdin closes
try:
mcp.run()
except Exception:
# Server might get EOF or other errors when stdin closes
pass
""")
# Write the server script to a temporary file
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
server_script = f.name
f.write(server_code)
try:
# This test manually manages the process to verify stdin-based shutdown
# Start the server process
process = await _create_platform_compatible_process(
command=sys.executable, args=[server_script], env=None, errlog=sys.stderr, cwd=None
)
# Wait for server to start
with anyio.move_on_after(10.0):
while not Path(startup_marker).exists():
await anyio.sleep(0.1)
# Check if process is still running
if hasattr(process, "returncode") and process.returncode is not None: # pragma: lax no cover
pytest.fail(f"Server process exited with code {process.returncode}")
assert Path(startup_marker).exists(), "Server startup marker not created"
# Close stdin to signal shutdown
if process.stdin: # pragma: no branch
await process.stdin.aclose()
# Wait for process to exit gracefully
try:
with anyio.fail_after(5.0): # Increased from 2.0 to 5.0
await process.wait()
except TimeoutError: # pragma: lax no cover
# If it doesn't exit after stdin close, terminate it
process.terminate()
await process.wait()
# Check if cleanup ran
with anyio.move_on_after(5.0):
while not Path(cleanup_marker).exists(): # pragma: lax no cover
await anyio.sleep(0.1)
# Verify the cleanup ran - stdin closure enables graceful shutdown
assert Path(cleanup_marker).exists(), "Server cleanup marker not created - stdin-based shutdown failed"
assert Path(cleanup_marker).read_text() == "cleaned up"
finally:
# Clean up files
for path in [server_script, startup_marker, cleanup_marker]:
try: # pragma: lax no cover
Path(path).unlink()
except FileNotFoundError: # pragma: lax no cover
pass

View file

@ -1,5 +1,6 @@
"""Test for issue #552: stdio_client hangs on Windows."""
import json
import sys
from textwrap import dedent
@ -8,41 +9,36 @@ import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.types import LATEST_PROTOCOL_VERSION, InitializeResult
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") # pragma: no cover
@pytest.mark.anyio
async def test_windows_stdio_client_with_session():
"""Test the exact scenario from issue #552: Using ClientSession with stdio_client.
async def test_initialize_succeeds_and_shutdown_returns_after_the_server_exits_mid_session():
"""Initialize completes and shutdown returns when the server exits mid-session.
This reproduces the original bug report where stdio_client hangs on Windows 11
when used with ClientSession.
This is the proactor pipe scenario that hung on Windows 11 (issue #552). The positive
assertion matters: a session that errors quickly would also "not hang".
"""
# Create a minimal MCP server that responds to initialization
server_script = dedent("""
# A minimal server: answer initialize correctly, then exit.
server_script = dedent(f"""
import json
import sys
# Read initialization request
line = sys.stdin.readline()
request = json.loads(line)
# Send initialization response
response = {
response = {{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "1.0",
"capabilities": {},
"serverInfo": {"name": "test-server", "version": "1.0"}
}
}
"id": request["id"],
"result": {{
"protocolVersion": {json.dumps(LATEST_PROTOCOL_VERSION)},
"capabilities": {{}},
"serverInfo": {{"name": "test-server", "version": "1.0"}}
}}
}}
print(json.dumps(response))
sys.stdout.flush()
# Exit after a short delay
import time
time.sleep(0.1)
sys.exit(0)
""").strip()
params = StdioServerParameters(
@ -50,14 +46,11 @@ async def test_windows_stdio_client_with_session():
args=["-c", server_script],
)
# This is the exact pattern from the bug report
with anyio.fail_after(10):
try:
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Should exit ClientSession without hanging
# Should exit stdio_client without hanging
except Exception:
# Connection errors are expected when process exits
pass
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.server_info.name == "test-server"
# Exiting ClientSession and stdio_client must not hang even though the
# server process is already gone.

View file

@ -1,4 +1,4 @@
"""Test the elicitation feature using stdio transport."""
"""Test the elicitation feature over the in-memory client transport."""
from typing import Any
@ -58,9 +58,9 @@ async def call_tool_and_assert(
@pytest.mark.anyio
async def test_stdio_elicitation():
"""Test the elicitation feature using stdio transport."""
mcp = MCPServer(name="StdioElicitationServer")
async def test_elicitation_accept_returns_the_users_answer_to_the_tool():
"""An accepted elicitation delivers the user's content back to the requesting tool."""
mcp = MCPServer(name="ElicitationServer")
create_ask_user_tool(mcp)
# Create a custom handler for elicitation requests
@ -76,9 +76,9 @@ async def test_stdio_elicitation():
@pytest.mark.anyio
async def test_stdio_elicitation_decline():
"""Test elicitation with user declining."""
mcp = MCPServer(name="StdioElicitationDeclineServer")
async def test_elicitation_decline_reaches_the_tool_without_content():
"""A declined elicitation reports the decline to the tool, with no content attached."""
mcp = MCPServer(name="ElicitationDeclineServer")
create_ask_user_tool(mcp)
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):

View file

@ -1,17 +1,26 @@
import io
import sys
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from io import TextIOWrapper
import anyio
import pytest
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
@pytest.mark.anyio
async def test_stdio_server():
async def test_stdio_server_round_trips_messages_over_injected_streams() -> None:
"""stdio_server frames JSON-RPC messages as one line each in both directions.
Parses one message per stdin line and writes each outgoing message as exactly one
line, driven over injected in-process streams.
"""
stdin = io.StringIO()
stdout = io.StringIO()
@ -24,52 +33,45 @@ async def test_stdio_server():
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
stdin.seek(0)
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
received_messages: list[JSONRPCMessage] = []
async with read_stream:
async for message in read_stream:
if isinstance(message, Exception): # pragma: no cover
raise message
received_messages.append(message.message)
if len(received_messages) == 2:
break
with anyio.fail_after(5):
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
async with read_stream:
received_messages: list[JSONRPCMessage] = []
for _ in range(2):
received = await read_stream.receive()
assert not isinstance(received, Exception)
received_messages.append(received.message)
# Verify received messages
assert len(received_messages) == 2
assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
# Test sending responses from the server
responses = [
JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=4, result={}),
]
responses = [
JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=4, result={}),
]
async with write_stream:
for response in responses:
session_message = SessionMessage(response)
await write_stream.send(session_message)
await write_stream.send(SessionMessage(response))
await write_stream.aclose()
stdout.seek(0)
output_lines = stdout.readlines()
assert len(output_lines) == 2
received_responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in output_lines]
assert len(received_responses) == 2
assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
"""Non-UTF-8 bytes on stdin must not crash the server.
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
Invalid bytes are replaced with U+FFFD, which then fails JSON parsing and
is delivered as an in-stream exception. Subsequent valid messages must
still be processed.
Invalid bytes are replaced with U+FFFD, fail JSON parsing, and arrive as an in-stream
exception; subsequent valid messages are still processed.
"""
# \xff\xfe are invalid UTF-8 start bytes.
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
@ -92,3 +94,78 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid
class _KeepOpenBytesIO(io.BytesIO):
"""A BytesIO that survives its TextIOWrapper being closed.
Lets the test read what was written after `run()` has torn the wrapper down.
"""
def close(self) -> None:
pass
def _run_stdio_bounded(server: MCPServer) -> None:
"""Run the blocking `server.run("stdio")` in a daemon thread joined with a 5s bound.
`run()` creates its own event loop, so a sync test cannot arm `anyio.fail_after`;
the join timeout turns a run loop that never returns on stdin EOF into a red test
instead of a silent CI hang. An exception escaping `run()` still fails the test:
pytest's unhandled-thread warning is escalated by `filterwarnings = ["error"]`.
"""
def target() -> None:
server.run("stdio")
thread = threading.Thread(target=target, daemon=True)
thread.start()
thread.join(5)
assert not thread.is_alive(), 'run("stdio") did not return after stdin EOF'
def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves over process stdio and returns at stdin EOF.
Answers a request over the process's stdio and returns when stdin reaches EOF,
rather than serving forever.
"""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
captured = _KeepOpenBytesIO()
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8"))
_run_stdio_bounded(MCPServer(name="RunStdioServer"))
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Code after `yield` in a lifespan runs when stdin EOF ends `run("stdio")`.
Regression lock for the issue #1027 shutdown chain: the run loop must end on
stdin EOF and unwind the lifespan rather than be killed before returning.
"""
events: list[str] = []
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[None]:
events.append("setup")
try:
yield
finally:
events.append("cleanup")
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
captured = _KeepOpenBytesIO()
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8"))
_run_stdio_bounded(MCPServer(name="LifespanStdioServer", lifespan=lifespan))
assert events == ["setup", "cleanup"]
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})

View file

@ -1,10 +0,0 @@
"""Windows-specific test utilities."""
def escape_path_for_python(path: str) -> str:
"""Escape a file path for use in Python code strings.
Converts backslashes to forward slashes which work on all platforms
and don't need escaping in Python strings.
"""
return repr(path.replace("\\", "/"))

View file

View file

View file

@ -0,0 +1,80 @@
"""Kernel-synchronized liveness probes for the real-subprocess stdio lifecycle suite.
A spawned (grand)child connects back to a test-owned TCP listener and sends
`b'alive'`; the kernel then provides every signal a test needs, with no sleeps or
polling. The kernel closes all of a process's file descriptors on exit, so EOF
(clean close / FIN) or `BrokenResourceError` (abrupt close / RST, typical of
SIGKILL and Windows job termination) proves death; only a running process can
answer an echo, so a reply proves liveness without racing a kill.
Extracted from the real-process section of tests/client/test_stdio.py; the two
copies on this branch are deliberate -- consolidating them is follow-up work.
"""
import anyio
import anyio.abc
import pytest
def connect_back_script(port: int, *, echo: bool = False) -> str:
"""Return a `python -c` script body that connects to 127.0.0.1:`port` and sends `b'alive'`.
After the banner the script blocks forever -- or, with `echo=True`, echoes every
received chunk back so `assert_peer_echoes` can prove the process still runs.
"""
# lax no cover: echo mode is used only by POSIX-gated tests; Windows runners enforce 100% per job.
if echo: # pragma: lax no cover
tail = "while True:\n data = s.recv(65536)\n if not data:\n break\n s.sendall(data)\n"
else:
tail = "time.sleep(3600)\n"
return f"import socket, time\ns = socket.create_connection(('127.0.0.1', {port}))\ns.sendall(b'alive')\n" + tail
async def open_liveness_listener() -> tuple[anyio.abc.SocketListener, int]:
"""Open a TCP listener on localhost and return it along with its port."""
multi = await anyio.create_tcp_listener(local_host="127.0.0.1")
sock = multi.listeners[0]
assert isinstance(sock, anyio.abc.SocketListener)
addr = sock.extra(anyio.abc.SocketAttribute.local_address)
# IPv4 local_address is (host: str, port: int)
assert isinstance(addr, tuple) and len(addr) >= 2 and isinstance(addr[1], int)
return sock, addr[1]
async def accept_alive(sock: anyio.abc.SocketListener) -> anyio.abc.SocketStream:
"""Accept one connection and assert the peer sent `b'alive'`.
Reads until the full 5-byte banner arrives (TCP may legally split even a tiny
send). Callers bound this with `anyio.fail_after` to catch a subprocess that
never started.
"""
stream = await sock.accept()
msg = b""
while len(msg) < 5:
msg += await stream.receive(5 - len(msg))
assert msg == b"alive", f"expected b'alive', got {msg!r}"
return stream
async def assert_stream_closed(stream: anyio.abc.SocketStream) -> None:
"""Assert the peer holding the other end of `stream` has terminated."""
with anyio.fail_after(5.0), pytest.raises((anyio.EndOfStream, anyio.BrokenResourceError)):
await stream.receive(1)
async def assert_peer_echoes(stream: anyio.abc.SocketStream) -> None: # pragma: lax no cover
"""Assert the peer holding the other end of `stream` is still running.
Round-trips one echo through the stream (the peer must use `echo=True`); a dead
process can never answer, so this cannot pass spuriously.
lax no cover: only POSIX-gated survival tests call this; Windows runners
enforce 100% coverage per job.
"""
with anyio.fail_after(5.0):
await stream.send(b"ping")
# Read until the full echo has arrived: TCP may legally split even a tiny send.
echoed = b""
while len(echoed) < 4:
echoed += await stream.receive(4 - len(echoed))
assert echoed == b"ping", f"expected b'ping', got {echoed!r}"

View file

@ -0,0 +1,77 @@
"""Fixtures for the stdio lifecycle suite.
Provides recording seams around `stdio_client`'s spawn and tree-termination
internals (the real implementations still run), plus a teardown that keeps a
crashed test from orphaning its sleep-forever subprocesses.
"""
import os
import signal
import sys
from collections.abc import Generator
from contextlib import suppress
from pathlib import Path
from typing import TextIO
import anyio.abc
import pytest
from mcp.client import stdio
from mcp.client.stdio import _create_platform_compatible_process, _terminate_process_tree
from mcp.os.win32.utilities import FallbackProcess
@pytest.fixture
def spawned_processes(
monkeypatch: pytest.MonkeyPatch,
) -> Generator[list[anyio.abc.Process | FallbackProcess]]:
"""Record every process `stdio_client` spawns; the real spawn still runs.
Teardown SIGKILLs each spawn-time process group on POSIX: the safety net for a
test that dies mid-body and the reaper for deliberate survivors. On Windows
there is no group to signal (the Job Object covers strays).
"""
spawned: list[anyio.abc.Process | FallbackProcess] = []
async def recording_spawn(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr,
cwd: Path | str | None = None,
) -> anyio.abc.Process | FallbackProcess:
process = await _create_platform_compatible_process(command, args, env, errlog, cwd)
spawned.append(process)
return process
monkeypatch.setattr(stdio, "_create_platform_compatible_process", recording_spawn)
yield spawned
_kill_spawn_groups(spawned)
@pytest.fixture
def terminate_calls(monkeypatch: pytest.MonkeyPatch) -> list[anyio.abc.Process | FallbackProcess]:
"""Record every invocation of `stdio_client`'s tree-termination seam; the real termination still runs.
An empty list after the context exits proves the graceful path: a FIN looks the
same whether the peer exited on stdin closure or was killed.
"""
terminated: list[anyio.abc.Process | FallbackProcess] = []
async def recording_terminate(process: anyio.abc.Process | FallbackProcess) -> None:
terminated.append(process)
await _terminate_process_tree(process)
monkeypatch.setattr(stdio, "_terminate_process_tree", recording_terminate)
return terminated
# lax no cover: registered on every platform but a no-op on Windows, whose runners enforce 100% per job.
def _kill_spawn_groups(spawned: list[anyio.abc.Process | FallbackProcess]) -> None: # pragma: lax no cover
"""SIGKILL each spawn-time process group; see `spawned_processes`."""
if sys.platform == "win32":
return
for process in spawned:
# macOS killpg raises EPERM for a group holding only unreaped zombies.
with suppress(ProcessLookupError, PermissionError):
os.killpg(process.pid, signal.SIGKILL)

View file

@ -0,0 +1,276 @@
"""Real-subprocess stdio lifecycle tests that hold on both POSIX and Windows.
The `stdio_client` tests each launch a real server through the public API and pin
one lifecycle behaviour, with kernel-level liveness sockets as the only
synchronization; the `FallbackProcess` tests wrap a raw `subprocess.Popen`
directly. Platform-divergent shutdown policy lives in test_posix.py /
test_windows.py; the full protocol round trip is pinned by
tests/interaction/transports/test_stdio.py and in-process shutdown logic by
tests/client/test_stdio.py.
"""
import os
import subprocess
import sys
import threading
from contextlib import AsyncExitStack
from pathlib import Path
import anyio
import anyio.abc
import pytest
from mcp.client import stdio
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from tests.transports.stdio._liveness import (
accept_alive,
assert_stream_closed,
connect_back_script,
open_liveness_listener,
)
@pytest.mark.anyio
async def test_a_server_that_exits_on_stdin_close_is_reaped_and_never_terminated(
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""The happy path: closing stdin alone shuts a well-behaved server down.
The server exits with code 0 and the escalation seam is never invoked.
"""
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
# The server exits on its own at stdin EOF -- the well-behaved response
# to shutdown's first step.
server = (
f"import socket, sys\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"sys.stdin.read()\n"
)
params = StdioServerParameters(command=sys.executable, args=["-c", server])
# The bound covers one interpreter cold start on a loaded runner; a healthy
# run takes well under a second.
with anyio.fail_after(10.0):
async with stdio_client(params):
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
await assert_stream_closed(stream)
assert spawned_processes[0].returncode == 0
assert terminate_calls == []
@pytest.mark.anyio
async def test_cancelling_the_client_mid_session_terminates_the_whole_server_tree(
monkeypatch: pytest.MonkeyPatch,
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""Cancellation still runs the full shutdown against a real process tree.
Cancellation here stands in for a client timeout or app shutdown: a server that
ignores stdin closure is escalated against, and its child dies with it.
"""
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 0.2)
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
child = connect_back_script(port)
# The parent never reads stdin and blocks forever, so only the escalation
# can end it -- which cancellation must not skip.
parent = f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {child!r}])\n" + connect_back_script(
port
)
params = StdioServerParameters(command=sys.executable, args=["-c", parent])
entered = anyio.Event()
# Cancel a scope owned by the client's task, not the test's task group: a
# host self-cancel is delivered by throwing through this test function's
# suspended frames, and Python 3.11's tracer loses coverage events after
# such a throw() traversal (python/cpython#106749).
cancel_scope = anyio.CancelScope()
async def run_client_until_cancelled() -> None:
with cancel_scope:
async with stdio_client(params):
entered.set()
await anyio.sleep_forever()
streams: list[anyio.abc.SocketStream] = []
# The bound covers two interpreter cold starts on a loaded runner plus the
# shortened escalation wait; a healthy run takes around a second.
with anyio.fail_after(10.0):
async with anyio.create_task_group() as tg:
tg.start_soon(run_client_until_cancelled)
await entered.wait()
for _ in range(2):
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
streams.append(stream)
cancel_scope.cancel()
for stream in streams:
await assert_stream_closed(stream)
assert terminate_calls == spawned_processes
@pytest.mark.anyio
async def test_a_server_that_exits_mid_session_keeps_its_own_exit_code(
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""A server that dies on its own mid-session is reaped with the exit code it chose.
The client surfaces the child's true status rather than synthesizing one, and
the escalation seam confirms nothing was terminated along the way.
"""
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
server = (
f"import socket, sys\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"sys.exit(7)\n"
)
params = StdioServerParameters(command=sys.executable, args=["-c", server])
# The bound covers one interpreter cold start on a loaded runner; a healthy
# run takes well under a second.
with anyio.fail_after(10.0):
# no branch: coverage mis-traces the exit arcs of a nested `async with` on 3.11+.
async with stdio_client(params): # pragma: no branch
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
# The server is already gone before shutdown begins.
await assert_stream_closed(stream)
assert spawned_processes[0].returncode == 7
assert terminate_calls == []
@pytest.mark.anyio
async def test_server_stderr_output_reaches_the_errlog_file(
tmp_path: Path,
spawned_processes: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""What the server writes to stderr lands in the file passed as `errlog`.
The spawn hands over errlog's file descriptor as the child's stderr, so it must
be a real file -- an in-memory StringIO has no fileno.
"""
marker = "stdio-lifecycle stderr marker 4242"
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
server = (
f"import socket, sys\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"sys.stderr.write({marker!r} + '\\n')\n"
f"sys.stderr.flush()\n"
f"sys.stdin.read()\n"
)
params = StdioServerParameters(command=sys.executable, args=["-c", server])
with (tmp_path / "errlog.txt").open("w+", encoding="utf-8") as errlog:
# The bound covers one interpreter cold start on a loaded runner; a
# healthy run takes well under a second.
with anyio.fail_after(10.0):
async with stdio_client(params, errlog=errlog):
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
# The server exited on stdin EOF, so every stderr write it made has
# reached the file descriptor.
errlog.seek(0)
content = errlog.read()
assert marker in content
assert spawned_processes[0].returncode == 0
@pytest.mark.skipif(
not hasattr(os, "waitid"), reason="needs os.waitid(WNOWAIT); absent on Windows and macOS before 3.13"
)
# lax no cover: Windows runners enforce 100% per job but lack os.waitid and skip this
# test; test_windows.py's SelectorEventLoop lifecycle test exercises the property there.
def test_fallback_process_reports_death_through_returncode_without_a_wait_call() -> None: # pragma: lax no cover
"""`FallbackProcess.returncode` observes process death on its own.
Pre-fix it returned Popen's cached value, which stays None until someone calls wait()/poll().
`os.waitid(WEXITED | WNOWAIT)` waits for the child to become reapable without
reaping it or priming Popen's cache (which would mask the regression); the
pre-fix cached read would still see None here. stdout EOF is NOT such a signal:
the kernel closes the pipes before the exit status is published, so an
EOF-then-assert version flakes.
"""
popen = subprocess.Popen(
[sys.executable, "-c", "pass"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
assert popen.stdin is not None and popen.stdout is not None
try:
process = FallbackProcess(popen)
os.waitid(os.P_PID, popen.pid, os.WEXITED | os.WNOWAIT)
assert process.returncode == 0
finally:
popen.stdin.close()
popen.stdout.close()
# The WNOWAIT above left the child unreaped; reap it so no zombie (and no
# Popen ResourceWarning) outlives the test.
popen.wait()
@pytest.mark.anyio
async def test_fallback_process_wait_is_cancellable_while_the_child_lives() -> None:
"""`FallbackProcess.wait()` honours cancellation while the child is still running.
Pre-fix it parked `Popen.wait()` in a worker thread anyio will not abandon,
which blocks every cancellation aimed at it. Runs everywhere: the wrapper holds
a plain Popen.
"""
popen = subprocess.Popen(
[sys.executable, "-c", "import sys; sys.stdin.read()"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
assert popen.stdin is not None and popen.stdout is not None
# Pre-fix, no timeout below can fire while the worker thread is parked in
# Popen.wait(); killing the child turns that regression's hang into a clean failure.
watchdog = threading.Timer(8.0, popen.kill)
watchdog.start()
try:
process = FallbackProcess(popen)
# move_on_after's short deadline is the time-based feature under test --
# cancellability -- not a wait for an async condition.
with anyio.fail_after(5):
with anyio.move_on_after(0.1) as scope:
await process.wait()
assert scope.cancelled_caught
# Only the wait was cancelled; the child itself is untouched.
assert popen.poll() is None
finally:
watchdog.cancel()
popen.kill()
popen.wait()
popen.stdin.close()
popen.stdout.close()

View file

@ -0,0 +1,116 @@
"""POSIX-only stdio lifecycle tests: a gracefully-exited server's children survive the client shutdown.
SDK-defined policy, not spec-mandated (docs/migration.md, "`stdio_client` no
longer kills children of a gracefully-exited server on POSIX"). Windows has the
opposite documented outcome; see tests/transports/stdio/test_windows.py.
"""
import errno
import sys
from contextlib import suppress
import anyio
import anyio.abc
import pytest
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from tests.transports.stdio._liveness import (
accept_alive,
assert_peer_echoes,
connect_back_script,
open_liveness_listener,
)
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX process-group semantics")
@pytest.mark.anyio
# lax no cover: the per-job 100% coverage gate also runs on Windows, where this file is skipped.
async def test_a_gracefully_exiting_servers_child_survives_the_client_shutdown( # pragma: lax no cover
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""A server that exits on stdin closure keeps its background child running after `stdio_client` returns.
The client never escalates against the gracefully-exited server. SDK-defined
policy per docs/migration.md; regression for the pre-fix client that
tree-killed the child. The Windows twin in test_windows.py pins the opposite outcome.
"""
sock, port = await open_liveness_listener()
async with sock:
child = connect_back_script(port, echo=True)
# The server hands its inherited pipes to a child, then exits as soon as
# its stdin closes: the well-behaved graceful path.
server = f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {child!r}])\nsys.stdin.read()\n"
params = StdioServerParameters(command=sys.executable, args=["-c", server])
# Two interpreter cold starts on a loaded runner; healthy runs take ~0.3s.
with anyio.fail_after(10.0):
async with stdio_client(params):
child_stream = await accept_alive(sock)
async with child_stream:
# Only a live process answers an echo: the child survived shutdown.
await assert_peer_echoes(child_stream)
# A FIN-shaped probe cannot tell graceful exit from a kill; the seam can:
# no escalation was invoked, and the leader exited 0 on stdin closure.
assert terminate_calls == []
leader = spawned_processes[0]
assert leader.returncode == 0
# The child is deliberately left running; the spawned_processes teardown
# SIGKILLs the spawn-time process group to reap it.
@pytest.mark.anyio
@pytest.mark.usefixtures("spawned_processes") # failure-path safety net for the parked child
# lax no cover: same Windows-runner coverage-gate reason as above.
async def test_a_surviving_childs_write_to_the_inherited_stdout_fails_with_epipe() -> None: # pragma: lax no cover
"""A surviving child writing to the stdout pipe it inherited from the server gets EPIPE once the client is gone.
The pipe's only read end was the client's, and shutdown closed it
deterministically rather than at GC time. Pins the docs/migration.md claim
"a surviving child that keeps writing to an inherited stdout receives
EPIPE/SIGPIPE once the client is gone" (SDK-defined).
Steps: the server hands its stdio pipes to a child and exits on stdin closure;
the child parks on its socket until `stdio_client` has fully exited (so the
write cannot race transport teardown), then writes one byte to its inherited
fd 1 and reports the errno (0 on success) back over the socket.
"""
sock, port = await open_liveness_listener()
async with sock:
# Pin SIGPIPE to SIG_IGN explicitly (CPython already starts that way) so
# the write fails with EPIPE instead of relying on interpreter startup details.
child = (
f"import os, signal, socket\n"
f"signal.signal(signal.SIGPIPE, signal.SIG_IGN)\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"s.recv(4)\n"
f"try:\n"
f" os.write(1, b'x')\n"
f" result = b'0'\n"
f"except OSError as e:\n"
f" result = str(e.errno).encode()\n"
f"s.sendall(result)\n"
)
server = f"import subprocess, sys\nsubprocess.Popen([sys.executable, '-c', {child!r}])\nsys.stdin.read()\n"
params = StdioServerParameters(command=sys.executable, args=["-c", server])
# Two interpreter cold starts on a loaded runner; healthy runs take ~0.3s.
with anyio.fail_after(10.0):
async with stdio_client(params):
child_stream = await accept_alive(sock)
async with child_stream:
# The context has fully exited: the transport, and with it the
# pipe's only read end, is closed. Release the child's write.
await child_stream.send(b"go")
# The child sends its errno report and exits, so read to EOF: the
# complete reply is everything before the kernel's FIN.
reply = b""
with suppress(anyio.EndOfStream):
while True:
reply += await child_stream.receive(16)
assert int(reply) == errno.EPIPE, f"child reported errno {reply!r}, expected EPIPE"

View file

@ -0,0 +1,235 @@
"""Windows-only stdio lifecycle behaviors, against real subprocesses.
Each test pins a contract that exists only on Windows: Job-Object reaping of a
gracefully-exited server's children (the deliberate divergence from the POSIX
policy in test_posix.py), the SelectorEventLoop fallback wrapper, and the CRLF
line endings a native text-mode server emits. Synchronization is kernel-level
only (liveness sockets); see `_liveness`.
Per-test no-cover pragmas (as in tests/issues/test_552_windows_hang.py): bodies run
only on windows-latest CI legs, the per-job 100% gate would count them uncovered on
non-Windows runners, and strict-no-cover is skipped on Windows where they execute.
"""
import asyncio
import sys
from contextlib import AsyncExitStack
from pathlib import Path
import anyio
import anyio.abc
import pytest
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from mcp.shared.message import SessionMessage
from mcp.types import JSONRPCRequest, JSONRPCResponse
from tests.transports.stdio._liveness import (
accept_alive,
assert_stream_closed,
connect_back_script,
open_liveness_listener,
)
pytestmark = [
pytest.mark.anyio,
pytest.mark.skipif(sys.platform != "win32", reason="Windows Job Object / event-loop semantics"),
]
async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_closes( # pragma: no cover
tmp_path: Path,
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""A gracefully-exited server's child is killed deterministically when shutdown closes the job handle.
The server exits cleanly on stdin closure, leaving a child behind; shutdown's
close of the server's Job Object handle (`close_process_job` +
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills that child deterministically, not at
GC time. Documented divergence from POSIX (docs/migration.md; the POSIX twin is
test_posix.py::test_a_gracefully_exiting_servers_child_survives_the_client_shutdown).
`terminate_calls == []` is the load-bearing distinction: the child died through
the graceful path's job-handle close, not the escalation's `TerminateJobObject`;
the two kills are indistinguishable on the socket.
Both processes connect back and their stderr is captured via `errlog`, so a
timeout failure can report which process never showed and the child's fate
(xdist swallows subprocess stderr on CI).
"""
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
# The startup marker (and any child traceback, via stderr=sys.stderr below)
# lands in errlog, splitting "never started" from "started but never connected".
child = "import sys\nprint('child-started', file=sys.stderr, flush=True)\n" + connect_back_script(port)
# The server spawns a child, connects back itself, then exits as soon as
# its stdin closes: the graceful path, so the escalation never runs.
# The child inherits Job membership: the SDK assigns the server to the Job
# synchronously after spawn, long before the cold-starting interpreter can
# Popen the child (membership is inherited at CreateProcess, never
# acquired retroactively).
#
# The child's stdin must be DEVNULL: CPython startup queries fd 0, and
# Windows serializes that query behind the server's pending blocking
# `sys.stdin.read()` on the inherited pipe, so the child would freeze at
# interpreter startup until the next inbound byte or EOF.
#
# After stdin EOF ends the server, it reports the child's `poll()` status:
# `None` means alive at server exit; an exit/NTSTATUS code names the killer.
server = (
f"import socket, subprocess, sys\n"
f"try:\n"
f" p = subprocess.Popen([sys.executable, '-c', {child!r}], "
f"stdin=subprocess.DEVNULL, stderr=sys.stderr)\n"
f"except BaseException as exc:\n"
f" print(exc, file=sys.stderr, flush=True)\n"
f" raise\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"sys.stdin.read()\n"
f"print('child-rc:%s' % p.poll(), file=sys.stderr, flush=True)\n"
)
server_params = StdioServerParameters(command=sys.executable, args=["-c", server])
with (tmp_path / "errlog.txt").open("w+", encoding="utf-8") as errlog:
def server_stderr() -> str:
errlog.seek(0)
return errlog.read()
streams: list[anyio.abc.SocketStream] = []
spawn_started = anyio.current_time()
entered_at: float | None = None
try:
# Two interpreter cold starts on a loaded runner; healthy runs
# take well under a second.
with anyio.fail_after(15.0):
async with stdio_client(server_params, errlog=errlog):
entered_at = anyio.current_time()
# The server and child race to connect; accept both,
# order-agnostic (accept_alive verifies each banner).
for _ in range(2):
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
streams.append(stream)
except TimeoutError:
# `stdio_client.__aexit__` has already completed its shielded shutdown,
# so the stderr read carries the server's final `child-rc` line, not a
# mid-flight snapshot.
missing_leg = "the server never ran its connect line" if not streams else "the child never connected"
spawn_split = (
"the context never entered"
if entered_at is None
else f"the context entered {entered_at - spawn_started:.1f}s after spawn began"
)
pytest.fail(
f"{len(streams)}/2 liveness connections arrived ({missing_leg}); "
f"{spawn_split}; server stderr: {server_stderr()!r}"
)
# Context exit closed the job handle: KILL_ON_JOB_CLOSE killed the
# child and the server exited gracefully, so both sockets close.
# The `spawned_processes` strong reference is load-bearing: `_process_jobs`
# is weak-keyed, so without it a GC between context exit and this assert
# could close the job handle itself and mask a regression in the
# deterministic close.
try:
for stream in streams:
await assert_stream_closed(stream)
except TimeoutError:
pytest.fail(f"a socket stayed open after shutdown; server stderr: {server_stderr()!r}")
leader = spawned_processes[0]
# The graceful path: the server exited on stdin closure with code 0,
# and the tree-termination escalation was never invoked.
assert leader.returncode == 0, server_stderr()
assert terminate_calls == [], server_stderr()
# Overrides the suite-wide anyio_backend fixture for this test only: a selector
# event loop cannot run asyncio subprocesses, forcing stdio_client onto FallbackProcess.
@pytest.mark.parametrize("anyio_backend", [("asyncio", {"loop_factory": asyncio.SelectorEventLoop})])
async def test_a_selector_event_loop_session_uses_the_fallback_process_and_exits_cleanly( # pragma: no cover
spawned_processes: list[anyio.abc.Process | FallbackProcess],
terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
"""Under a `SelectorEventLoop`, `stdio_client` falls back to `FallbackProcess` and still exits cleanly.
A selector event loop has no asyncio subprocess support, so `stdio_client`
falls back to the Popen-based `FallbackProcess` wrapper; a well-behaved server
still completes the full clean lifecycle: spawn, liveness, exit on stdin
closure, reaped, never escalated against.
The `isinstance` check is the engagement proof: if a future anyio gains selector
subprocess support, the spawn would silently return a normal Process. A hang here
most likely means the known fallback hazard documented in `stdio_client`'s
shutdown comment (reader thread parked in a synchronous `ReadFile`), which is
why this test pins only the clean-exit path, never a kill path.
"""
async with AsyncExitStack() as stack:
sock, port = await open_liveness_listener()
stack.push_async_callback(sock.aclose)
# Connect back for liveness, then exit as soon as stdin closes: the
# well-behaved server, so shutdown's first step suffices.
server = (
f"import socket, sys\n"
f"s = socket.create_connection(('127.0.0.1', {port}))\n"
f"s.sendall(b'alive')\n"
f"sys.stdin.read()\n"
)
server_params = StdioServerParameters(command=sys.executable, args=["-c", server])
# One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
with anyio.fail_after(10.0):
async with stdio_client(server_params):
stream = await accept_alive(sock)
stack.push_async_callback(stream.aclose)
# The engagement proof, asserted while the session is live.
assert isinstance(spawned_processes[0], FallbackProcess)
# The server exited on stdin closure: socket closed, exit code 0, and the
# escalation never fired.
await assert_stream_closed(stream)
assert spawned_processes[0].returncode == 0
assert terminate_calls == []
async def test_a_native_server_emitting_crlf_line_endings_round_trips_messages() -> None: # pragma: no cover
"""The client round-trips messages from a text-mode Windows server that frames its output with \\r\\n.
`TextIOWrapper`'s `newline=None` translates "\\n" to `os.linesep`, so such a
server emits \\r\\n; the client still parses each line because the reader
splits on "\\n" only and the JSON parser tolerates the trailing "\\r" as
whitespace. The SDK's own server writes through such a wrapper, so this
tolerance is load-bearing for Windows interop.
tests/issues/test_552_windows_hang.py exercises the same wire form implicitly
through `initialize()`; this test is the explicit owner of the framing claim.
"""
# Read one request, answer it via print() (which emits \r\n on Windows), then
# exit when stdin closes. json.loads/dumps keep the script free of SDK imports.
server = (
"import json, sys\n"
"line = sys.stdin.readline()\n"
"request = json.loads(line)\n"
"print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': {}}))\n"
"sys.stdout.flush()\n"
"sys.stdin.read()\n"
)
server_params = StdioServerParameters(command=sys.executable, args=["-c", server])
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
# One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
with anyio.fail_after(10.0):
async with stdio_client(server_params) as (read_stream, write_stream):
await write_stream.send(SessionMessage(ping))
received = await read_stream.receive()
# A reader that choked on the trailing \r would deliver a ValueError
# here instead of a parsed message.
assert isinstance(received, SessionMessage)
assert received.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={})