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
parent 19fe9faec8
commit bdc48e98b1
18 changed files with 2625 additions and 1110 deletions
+40
View File
@@ -105,6 +105,46 @@ The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been re
Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
### `terminate_windows_process` removed
The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been
removed. Process termination is handled internally by the `stdio_client` context
manager; there is no replacement API. The Windows tree-termination helper
`terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument —
the value was never used (Job Object termination is immediate).
### `stdio_client` no longer kills children of a gracefully-exited server on POSIX
When a server exits on its own after `stdio_client` closes its stdin, background
child processes the server leaves behind are no longer killed on POSIX — their
lifetime is the server's business. The old behavior was a side effect of a shutdown
wait gated on the stdio pipes closing rather than on process exit: a child holding
an inherited pipe made a well-behaved server look hung, so its whole process tree
was killed. (That gating is an asyncio behavior specific to Python 3.11+ — on
Python 3.10 and the trio backend the old wait already resolved on process exit, so
the spurious kill never fired there.) A server that does not exit within the grace
period is still terminated
along with its entire process group. On Windows, children stay in the server's Job
Object and are still killed at shutdown — now deterministically when the job handle
is closed, rather than whenever the handle happened to be garbage-collected.
If you relied on `stdio_client` killing everything the server spawned, make the
server terminate its own children on shutdown (its stdin reaching EOF is the
shutdown signal), or clean up the process tree from the host application after
`stdio_client` exits.
Two related shutdown refinements: `stdio_client` now closes its end of the pipes
deterministically at shutdown, so a surviving child that keeps writing to an
inherited stdout receives `EPIPE`/`SIGPIPE` once the client is gone (previously the
pipe lingered until garbage collection); and a failed write to a server that is
still running now surfaces as a closed connection (`CONNECTION_CLOSED`) on the read
side instead of leaving requests waiting indefinitely.
`terminate_posix_process_tree` now requires the process to lead its own process
group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
per-process terminate/kill fallback are gone. The win32 utilities logger is now
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
### Removed type aliases and classes
The following deprecated type aliases and classes have been removed from `mcp.types`:
+224 -140
View File
@@ -1,21 +1,33 @@
"""stdio client transport.
Runs an MCP server as a subprocess and exchanges newline-delimited JSON-RPC
messages with it over stdin/stdout. Two pipe tasks bridge the server's pipes
to the session's in-memory streams; shutdown follows the MCP spec sequence
(close stdin, wait, then kill the process tree) inside a cancellation shield
with every wait bounded, so a cancelled caller can neither leak a live server
process nor hang on one.
"""
import logging
import os
import sys
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Literal, TextIO
import anyio
import anyio.lowlevel
from anyio.abc import Process
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from anyio.abc import AsyncResource, Process
from anyio.streams.text import TextReceiveStream
from pydantic import BaseModel, Field
from mcp import types
from mcp.client._transport import TransportStreams
from mcp.os.posix.utilities import terminate_posix_process_tree
from mcp.os.win32.utilities import (
FallbackProcess,
ServerProcess,
close_process_job,
create_windows_process,
get_windows_executable_command,
terminate_windows_process_tree,
@@ -44,14 +56,24 @@ DEFAULT_INHERITED_ENV_VARS = (
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
)
# Timeout for process termination before falling back to force kill
# Grace period for the server to exit on its own after its stdin closes.
PROCESS_TERMINATION_TIMEOUT = 2.0
# Extra time after SIGTERM before SIGKILL; POSIX only (Windows kills hard).
FORCE_KILL_TIMEOUT = 2.0
# Time for the event loop to observe a kill; only an unkillable process runs this out.
_KILL_REAP_TIMEOUT = 2.0
# Time for the writer to flush accepted messages before stdin closes.
_WRITER_FLUSH_TIMEOUT = 0.5
# How often to poll returncode while waiting for the process to die.
_EXIT_POLL_INTERVAL = 0.01
def get_default_environment() -> dict[str, str]:
"""Returns a default environment object including only environment variables deemed
safe to inherit.
"""
"""Returns only the environment variables that are safe to inherit."""
env: dict[str, str] = {}
for key in DEFAULT_INHERITED_ENV_VARS:
@@ -76,150 +98,227 @@ class StdioServerParameters(BaseModel):
"""Command line arguments to pass to the executable."""
env: dict[str, str] | None = None
"""
The environment to use when spawning the process.
If not specified, the result of get_default_environment() will be used.
"""
"""Extra environment variables, merged over get_default_environment()."""
cwd: str | Path | None = None
"""The working directory to use when spawning the process."""
encoding: str = "utf-8"
"""
The text encoding used when sending/receiving messages to the server.
Defaults to utf-8.
"""
"""Text encoding for messages to and from the server."""
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
"""
The text encoding error handler.
See https://docs.python.org/3/library/codecs.html#codec-base-classes for
explanations of possible values.
"""
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
@asynccontextmanager
async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr):
"""Client transport for stdio: this will connect to a server by spawning a
process and communicating with it over stdin/stdout.
async def stdio_client(
server: StdioServerParameters, errlog: TextIO = sys.stderr
) -> AsyncGenerator[TransportStreams, None]:
"""Spawns an MCP server subprocess and connects to it over stdin/stdout.
Raises:
OSError: If the server process cannot be spawned.
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
"""
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
command = _get_executable_command(server.command)
write_stream: MemoryObjectSendStream[SessionMessage]
write_stream_reader: MemoryObjectReceiveStream[SessionMessage]
process = await _create_platform_compatible_process(
command=command,
args=server.args,
env=get_default_environment() | (server.env or {}),
errlog=errlog,
cwd=server.cwd,
)
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
# The spawn succeeded; no awaits until the task group is entered, or a
# cancellation delivered in the gap would leak the live process.
read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
try:
command = _get_executable_command(server.command)
shutting_down = False
writer_done = anyio.Event()
# Open process with stderr piped for capture
process = await _create_platform_compatible_process(
command=command,
args=server.args,
env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()),
errlog=errlog,
cwd=server.cwd,
)
except OSError:
# Clean up streams if process creation fails
await read_stream.aclose()
await write_stream.aclose()
await read_stream_writer.aclose()
await write_stream_reader.aclose()
raise
async def stdout_reader():
async def stdout_reader() -> None:
assert process.stdout, "Opened process is missing stdout"
stdout = TextReceiveStream(process.stdout, encoding=server.encoding, errors=server.encoding_error_handler)
try:
async with read_stream_writer:
buffer = ""
async for chunk in TextReceiveStream(
process.stdout,
encoding=server.encoding,
errors=server.encoding_error_handler,
):
lines = (buffer + chunk).split("\n")
buffer = lines.pop()
try:
# One line at a time; no read-ahead while a delivery is blocked.
buffer = ""
async for chunk in stdout:
lines = (buffer + chunk).split("\n")
buffer = lines.pop()
for line in lines:
try:
await read_stream_writer.send(_parse_line(line))
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
return # the session is gone; only the drain below remains
finally:
await _drain_stdout(process)
except anyio.ClosedResourceError:
pass # our own shutdown closed the stdout stream under the read
except (anyio.BrokenResourceError, ConnectionError):
# Teardown noise during shutdown, a real failure otherwise; either way
# the session sees clean closure when the read stream closes.
if not shutting_down:
logger.exception("Reading from the MCP server's stdout failed mid-session")
for line in lines:
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except Exception as exc: # pragma: no cover
logger.exception("Failed to parse JSONRPC message from server")
await read_stream_writer.send(exc)
continue
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except anyio.ClosedResourceError: # pragma: lax no cover
await anyio.lowlevel.checkpoint()
async def stdin_writer():
async def stdin_writer() -> None:
assert process.stdin, "Opened process is missing stdin"
try:
async with write_stream_reader:
async for session_message in write_stream_reader:
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
await process.stdin.send(
(json + "\n").encode(
encoding=server.encoding,
errors=server.encoding_error_handler,
)
)
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()
data = (json + "\n").encode(encoding=server.encoding, errors=server.encoding_error_handler)
await process.stdin.send(data)
except (anyio.ClosedResourceError, anyio.BrokenResourceError, OSError):
# The server may still be alive: close the read stream so the session
# sees the connection end instead of a request hanging forever.
await read_stream_writer.aclose()
finally:
writer_done.set()
async with anyio.create_task_group() as tg, process:
async def shutdown() -> None:
"""Winds the transport down: stop traffic, flush, stop the server, release the streams."""
# Unblock the reader into its drain: a server stuck writing stdout cannot
# read its stdin, so draining is what lets the flush below complete.
read_stream.close()
# Bounded window for the writer to flush already-accepted messages.
write_stream.close()
with anyio.move_on_after(_WRITER_FLUSH_TIMEOUT) as flush_scope:
await writer_done.wait()
if flush_scope.cancelled_caught:
await anyio.lowlevel.cancel_shielded_checkpoint() # resync coverage on 3.11 (gh-106749)
await _stop_server_process(process)
await _aclose_all(read_stream, write_stream, read_stream_writer, write_stream_reader)
# One pass so unblocked tasks exit via their except paths before the cancel.
await anyio.lowlevel.checkpoint()
async with anyio.create_task_group() as tg:
tg.start_soon(stdout_reader)
tg.start_soon(stdin_writer)
try:
yield read_stream, write_stream
finally:
# MCP spec: stdio shutdown sequence
# 1. Close input stream to server
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
# 3. Send SIGKILL if still not exited
if process.stdin: # pragma: no branch
try:
await process.stdin.aclose()
except Exception: # pragma: no cover
# stdin might already be closed, which is fine
pass
shutting_down = True
# Shutdown must finish even under caller cancellation, or the server
# process would leak; every wait inside is bounded. (Native
# task.cancel() and the fallback's worker threads can still defeat it.)
with anyio.CancelScope(shield=True):
await shutdown()
# Unstick pipe tasks a kill survivor's open pipe end could still block.
tg.cancel_scope.cancel()
# The cancel lands via throw(); one yield resyncs 3.11 coverage (gh-106749).
await anyio.lowlevel.cancel_shielded_checkpoint()
try:
# Give the process time to exit gracefully after stdin closes
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
await process.wait()
except TimeoutError:
# Process didn't exit from stdin closure, use platform-specific termination
# which handles SIGTERM -> SIGKILL escalation
await _terminate_process_tree(process)
except ProcessLookupError: # pragma: no cover
# Process already exited, which is fine
pass
await read_stream.aclose()
await write_stream.aclose()
await read_stream_writer.aclose()
await write_stream_reader.aclose()
def _parse_line(line: str) -> SessionMessage | Exception:
"""Parses one stdout line, returning parse errors as values for the session to surface."""
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except ValueError as exc:
logger.exception("Failed to parse JSONRPC message from server")
return exc
return SessionMessage(message)
async def _drain_stdout(process: ServerProcess) -> None:
"""Consumes and discards the server's remaining stdout.
Keeps a server flushing buffered output from blocking on a full pipe and
missing its chance to exit; shielded, raw bytes, ends when shutdown closes
the pipe.
"""
assert process.stdout
with anyio.CancelScope(shield=True):
with suppress(
anyio.EndOfStream,
anyio.ClosedResourceError,
anyio.BrokenResourceError,
ConnectionError,
OSError,
):
while True:
await process.stdout.receive()
async def _stop_server_process(process: ServerProcess) -> None:
"""Closes stdin, waits out the grace period, then kills the whole tree.
The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#shutdown
"""
assert process.stdin and process.stdout, "server process is spawned with pipes"
await _close_pipe(process.stdin)
if not await _wait_for_process_exit(process, PROCESS_TERMINATION_TIMEOUT):
await _terminate_process_tree(process)
# Until the event loop observes the death, the transport cannot close.
if not await _wait_for_process_exit(process, _KILL_REAP_TIMEOUT):
logger.warning("MCP server process %d is still alive after the kill escalation; abandoning it", process.pid)
# Reaps surviving Windows job members now, not at GC; no-op on POSIX.
close_process_job(process)
# A kill survivor can hold the stdout pipe open; poison the reader anyway.
await _close_pipe(process.stdout)
_close_subprocess_transport(process)
async def _close_pipe(stream: AsyncResource) -> None:
"""Closes a pipe stream, tolerating one already closed, broken, or contended."""
with suppress(OSError, anyio.BrokenResourceError, anyio.ClosedResourceError):
await stream.aclose()
async def _wait_for_process_exit(process: ServerProcess, timeout: float) -> bool:
"""Returns whether the process died within the timeout, by polling returncode.
Not process.wait(): on asyncio 3.11+ it also waits for pipe EOF, and a
child that inherited the pipes makes an exited server look hung.
"""
deadline = anyio.current_time() + timeout
while process.returncode is None:
if anyio.current_time() >= deadline:
return False
await anyio.sleep(_EXIT_POLL_INTERVAL)
return True
async def _terminate_process_tree(process: ServerProcess) -> None:
"""Kills the process and all its descendants.
POSIX: SIGTERM to the process group, SIGKILL after FORCE_KILL_TIMEOUT.
Windows: immediate Job Object termination (already a hard kill).
"""
if sys.platform == "win32": # pragma: no cover
await terminate_windows_process_tree(process)
else: # pragma: lax no cover
# The Windows-only FallbackProcess never reaches the POSIX path.
assert isinstance(process, Process)
await terminate_posix_process_tree(process, FORCE_KILL_TIMEOUT)
def _close_subprocess_transport(process: ServerProcess) -> None:
"""Closes the asyncio subprocess transport, if there is one.
The transport otherwise stays open (and warns at GC) while a surviving
descendant holds a pipe end; nothing public exposes it, hence the attribute
walk. No-op on trio and the Windows fallback.
"""
transport = getattr(getattr(process, "_process", None), "_transport", None)
# Duck-typed: uvloop's UVProcessTransport is not an asyncio.SubprocessTransport.
close = getattr(transport, "close", None)
if callable(close):
# close() on <=3.12 can raise PermissionError re-killing a setuid child.
with suppress(PermissionError):
close()
def _get_executable_command(command: str) -> str:
"""Get the correct executable command normalized for the current platform.
Args:
command: Base command (e.g., 'uvx', 'npx')
Returns:
str: Platform-appropriate command
"""
"""Normalizes the command for the current platform."""
if sys.platform == "win32": # pragma: no cover
return get_windows_executable_command(command)
else: # pragma: lax no cover
@@ -232,16 +331,15 @@ async def _create_platform_compatible_process(
env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr,
cwd: Path | str | None = None,
):
"""Creates a subprocess in a platform-compatible way.
) -> ServerProcess:
"""Spawns the server in its own kill scope.
Unix: Creates process in a new session/process group for killpg support
Windows: Creates process in a Job Object for reliable child termination
A new session/process group on POSIX, a Job Object on Windows.
"""
if sys.platform == "win32": # pragma: no cover
process = await create_windows_process(command, args, env, errlog, cwd)
return await create_windows_process(command, args, env, errlog, cwd)
else: # pragma: lax no cover
process = await anyio.open_process(
return await anyio.open_process(
[command, *args],
env=env,
stderr=errlog,
@@ -249,22 +347,8 @@ async def _create_platform_compatible_process(
start_new_session=True,
)
return process
async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None:
"""Terminate a process and all its children using platform-specific methods.
Unix: Uses os.killpg() for atomic process group termination
Windows: Uses Job Objects via pywin32 for reliable child process cleanup
Args:
process: The process to terminate
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
"""
if sys.platform == "win32": # pragma: no cover
await terminate_windows_process_tree(process, timeout_seconds)
else: # pragma: lax no cover
# FallbackProcess should only be used for Windows compatibility
assert isinstance(process, Process)
await terminate_posix_process_tree(process, timeout_seconds)
async def _aclose_all(*streams: AsyncResource) -> None:
"""Closes every given stream."""
for stream in streams:
await stream.aclose()
+42 -36
View File
@@ -3,55 +3,61 @@
import logging
import os
import signal
from contextlib import suppress
import anyio
from anyio.abc import Process
logger = logging.getLogger(__name__)
# How often to probe for surviving group members between SIGTERM and SIGKILL.
_GROUP_POLL_INTERVAL = 0.01
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
"""Terminate a process and all its children on POSIX systems.
"""Terminates a process and all its descendants on POSIX.
Uses os.killpg() for atomic process group termination.
Args:
process: The process to terminate
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
SIGTERMs the process group, waits up to timeout_seconds for it to
disappear, then SIGKILLs whatever remains. killpg reaches every descendant
atomically, even ones whose parent already exited; daemonizers that left
the group escape by design. A group only disappears once every member is
dead and reaped, so a client running as PID 1 should reap orphans (e.g.
docker run --init) or the wait below runs its full timeout.
"""
pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None)
if not pid:
# No PID means there's no process to terminate - it either never started,
# already exited, or we have an invalid process object
return
# The leader's pid is the pgid (start_new_session). Never use getpgid():
# it fails once the leader is reaped, even with live members left.
pgid = process.pid
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
return # the whole group is already gone
except PermissionError:
# EPERM never proves the group is gone (macOS raises it for zombie or
# foreign-euid members), so keep waiting and escalating.
logger.warning(
"No permission to signal some of process group %d; waiting for it to exit anyway", pgid, exc_info=True
)
with anyio.move_on_after(timeout_seconds):
while True:
try:
# Check if process group still exists (signal 0 = check only)
os.killpg(pgid, 0)
await anyio.sleep(0.1)
except ProcessLookupError:
return
with anyio.move_on_after(timeout_seconds):
while _group_alive(pgid):
# Reading returncode reaps the leader on trio; a zombie leader would
# otherwise keep the group alive for the full timeout.
_ = process.returncode
await anyio.sleep(_GROUP_POLL_INTERVAL)
return
try:
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
# ESRCH: died since the last probe. EPERM: we killed what we were allowed to.
with suppress(ProcessLookupError, PermissionError):
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError) as e:
logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate")
try:
process.terminate()
with anyio.fail_after(timeout_seconds):
await process.wait()
except Exception:
logger.warning(f"Process termination failed for PID {pid}, attempting force kill")
try:
process.kill()
except Exception:
logger.exception(f"Failed to kill process {pid}")
def _group_alive(pgid: int) -> bool:
"""Probes the group with signal 0; only ESRCH proves it is gone."""
try:
os.killpg(pgid, 0)
except ProcessLookupError:
return False
except PermissionError:
pass # unsignalable survivors or unreaped zombies; EPERM is ambiguous
return True
+125 -189
View File
@@ -4,16 +4,16 @@ import logging
import shutil
import subprocess
import sys
import weakref
from contextlib import suppress
from pathlib import Path
from typing import BinaryIO, TextIO, cast
from typing import BinaryIO, TextIO, TypeAlias, cast
import anyio
from anyio import to_thread
from anyio.abc import Process
from anyio.streams.file import FileReadStream, FileWriteStream
from typing_extensions import deprecated
logger = logging.getLogger("client.stdio.win32")
logger = logging.getLogger(__name__)
# Windows-specific imports for Job Objects
if sys.platform == "win32":
@@ -28,110 +28,86 @@ else:
win32job = None
pywintypes = None
JobHandle = int
# How often FallbackProcess polls the underlying Popen for exit.
_EXIT_POLL_INTERVAL = 0.01
# Job Object handle per spawned process, for tree termination at shutdown.
# Values stay pywin32 PyHANDLEs: if no pop site ever runs, the dying weak entry
# drops the last reference and the PyHANDLE destructor closes the handle, which
# is what makes KILL_ON_JOB_CLOSE reap an abandoned tree.
_process_jobs: "weakref.WeakKeyDictionary[Process | FallbackProcess, object]" = weakref.WeakKeyDictionary()
def get_windows_executable_command(command: str) -> str:
"""Get the correct executable command normalized for Windows.
"""Resolves the command to a Windows executable path.
On Windows, commands might exist with specific extensions (.exe, .cmd, etc.)
that need to be located for proper execution.
Args:
command: Base command (e.g., 'uvx', 'npx')
Returns:
str: Windows-appropriate command path
Tries the bare name first, then the common script extensions (.cmd, .bat,
.exe, .ps1).
"""
try:
# First check if command exists in PATH as-is
if command_path := shutil.which(command):
return command_path
# Check for Windows-specific extensions
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
ext_version = f"{command}{ext}"
if ext_path := shutil.which(ext_version):
return ext_path
# For regular commands or if we couldn't find special versions
return command
except OSError:
# Handle file system errors during path resolution
# (permissions, broken symlinks, etc.)
return command
return command # path probing failed (permissions, broken symlinks)
class FallbackProcess:
"""A fallback process wrapper for Windows to handle async I/O
when using subprocess.Popen, which provides sync-only FileIO objects.
"""Async wrapper around subprocess.Popen for SelectorEventLoop.
This wraps stdin and stdout into async-compatible
streams (FileReadStream, FileWriteStream),
so that MCP clients expecting async streams can work properly.
Windows event loops without async subprocess support get this Popen-backed
fallback, with anyio file streams wrapping the pipes.
"""
def __init__(self, popen_obj: subprocess.Popen[bytes]):
def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
self.popen: subprocess.Popen[bytes] = popen_obj
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
self.stderr = popen_obj.stderr # type: ignore[assignment]
stdin = popen_obj.stdin
stdout = popen_obj.stdout
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
self.stdin = FileWriteStream(cast(BinaryIO, stdin)) if stdin else None
self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None
async def __aenter__(self):
"""Support async context manager entry."""
return self
async def wait(self) -> int:
"""Waits for exit by polling the Popen.
async def __aexit__(
self,
exc_type: BaseException | None,
exc_val: BaseException | None,
exc_tb: object | None,
) -> None:
"""Terminate and wait on process exit inside a thread."""
A thread blocked in Popen.wait() cannot be cancelled by anyio, which
would defeat every timeout placed around this call.
"""
while (returncode := self.popen.poll()) is None:
await anyio.sleep(_EXIT_POLL_INTERVAL)
return returncode
def terminate(self) -> None:
"""Terminates the subprocess."""
self.popen.terminate()
await to_thread.run_sync(self.popen.wait)
# Close the file handles to prevent ResourceWarning
if self.stdin:
await self.stdin.aclose()
if self.stdout:
await self.stdout.aclose()
if self.stdin_raw:
self.stdin_raw.close()
if self.stdout_raw:
self.stdout_raw.close()
if self.stderr:
self.stderr.close()
async def wait(self):
"""Async wait for process completion."""
return await to_thread.run_sync(self.popen.wait)
def terminate(self):
"""Terminate the subprocess immediately."""
return self.popen.terminate()
def kill(self) -> None:
"""Kill the subprocess immediately (alias for terminate)."""
self.terminate()
"""Kills the subprocess (on Windows the same hard kill as terminate)."""
self.popen.kill()
@property
def pid(self) -> int:
"""Return the process ID."""
"""Returns the process ID."""
return self.popen.pid
@property
def returncode(self) -> int | None:
"""Return the exit code, or ``None`` if the process has not yet terminated."""
return self.popen.returncode
"""The exit code, or None while the process is still running.
Polls the Popen so death is observable without anyone calling wait().
"""
return self.popen.poll()
# ------------------------
# Updated function
# ------------------------
# The process handle stdio_client drives: anyio's Process, or the Popen-backed
# fallback used on Windows event loops without async subprocess support.
ServerProcess: TypeAlias = Process | FallbackProcess
async def create_windows_process(
@@ -141,53 +117,35 @@ async def create_windows_process(
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> Process | FallbackProcess:
"""Creates a subprocess in a Windows-compatible way with Job Object support.
"""Creates a subprocess with Job Object support for tree termination.
Attempts to use anyio's open_process for async subprocess creation.
In some cases this will throw NotImplementedError on Windows, e.g.,
when using the SelectorEventLoop, which does not support async subprocesses.
In that case, we fall back to using subprocess.Popen.
The process is automatically added to a Job Object to ensure all child
processes are terminated when the parent is terminated.
Args:
command (str): The executable to run
args (list[str]): List of command line arguments
env (dict[str, str] | None): Environment variables
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
cwd (Path | str | None): Working directory for the subprocess
Spawns via anyio's open_process; event loops without async subprocess
support (notably the SelectorEventLoop) raise NotImplementedError, in which
case the spawn falls back to a Popen-backed FallbackProcess. Either way the
process is then assigned to a Job Object so its children can be terminated
with it; children spawned before the assignment completes are not captured
(see the inline note below).
Returns:
Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
Process | FallbackProcess: The spawned process with async stdin/stdout streams.
"""
job = _create_job_object()
process = None
try:
# First try using anyio with Windows-specific flags to hide console window
process = await anyio.open_process(
[command, *args],
env=env,
# Ensure we don't create console windows for each process
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
if hasattr(subprocess, "CREATE_NO_WINDOW")
else 0,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
stderr=errlog,
cwd=cwd,
)
except NotImplementedError:
# If Windows doesn't support async subprocess creation, use fallback
# Windows event loops without async subprocess support (SelectorEventLoop)
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
except Exception:
# Try again without creation flags
process = await anyio.open_process(
[command, *args],
env=env,
stderr=errlog,
cwd=cwd,
)
# Children spawned before the assignment completes land outside the job
# (membership is inherited at CreateProcess, never acquired retroactively);
# if that ever bites, the fix is a CREATE_SUSPENDED spawn -> assign -> resume.
job = _create_job_object()
_maybe_assign_process_to_job(process, job)
return process
@@ -199,41 +157,26 @@ async def _create_windows_fallback_process(
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> FallbackProcess:
"""Create a subprocess using subprocess.Popen as a fallback when anyio fails.
This function wraps the sync subprocess.Popen in an async-compatible interface.
"""
try:
# Try launching with creationflags to avoid opening a new console window
popen_obj = subprocess.Popen(
[command, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog,
env=env,
cwd=cwd,
bufsize=0, # Unbuffered output
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
# If creationflags failed, fallback without them
popen_obj = subprocess.Popen(
[command, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog,
env=env,
cwd=cwd,
bufsize=0,
)
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""
popen_obj = subprocess.Popen(
[command, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog,
env=env,
cwd=cwd,
bufsize=0, # Unbuffered output
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return FallbackProcess(popen_obj)
def _create_job_object() -> int | None:
"""Create a Windows Job Object configured to terminate all processes when closed."""
if sys.platform != "win32" or not win32job:
def _create_job_object() -> object | None:
"""Creates a Windows Job Object configured to terminate all its processes when closed."""
if sys.platform != "win32" or not win32api or not win32job:
return None
job = None
try:
job = win32job.CreateJobObject(None, "")
extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation)
@@ -241,17 +184,20 @@ def _create_job_object() -> int | None:
extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info)
return job
except Exception as e:
logger.warning(f"Failed to create Job Object for process tree management: {e}")
except pywintypes.error:
logger.warning("Failed to create Job Object for process tree management", exc_info=True)
# If creation succeeded but configuration failed, close the handle now.
if job is not None:
_close_job_handle(job)
return None
def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None:
"""Try to assign a process to a job object.
def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object | None) -> None:
"""Assigns the process to the job and records it for tree termination.
If assignment fails for any reason, the job handle is closed.
On any failure the job handle is closed instead.
"""
if not job:
if job is None:
return
if sys.platform != "win32" or not win32api or not win32con or not win32job:
@@ -262,72 +208,62 @@ def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHan
win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid
)
if not process_handle:
raise Exception("Failed to open process handle")
raise pywintypes.error(0, "OpenProcess", "Failed to open process handle")
try:
win32job.AssignProcessToJobObject(job, process_handle)
process._job_object = job
finally:
win32api.CloseHandle(process_handle)
except Exception as e:
logger.warning(f"Failed to assign process {process.pid} to Job Object: {e}")
if win32api:
win32api.CloseHandle(job)
# Record only after the CloseHandle above succeeded: had it failed, the
# except below would close the job and KILL_ON_JOB_CLOSE takes the server.
_process_jobs[process] = job
except pywintypes.error:
logger.warning("Failed to assign process %d to Job Object", process.pid, exc_info=True)
_close_job_handle(job)
async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None:
"""Terminate a process and all its children on Windows.
def close_process_job(process: Process | FallbackProcess) -> None:
"""Closes the process's Job Object handle, if it still has one.
If the process has an associated job object, it will be terminated.
Otherwise, falls back to basic process termination.
Args:
process: The process to terminate
timeout_seconds: Timeout in seconds before force killing (default: 2.0)
KILL_ON_JOB_CLOSE makes the close also kill any members still alive,
deterministically rather than at GC time; a deliberate divergence from
POSIX, where a graceful server's children are left alive.
"""
if sys.platform != "win32":
return
job = getattr(process, "_job_object", None)
if job and win32job:
try:
win32job.TerminateJobObject(job, 1)
except Exception:
# Job might already be terminated
pass
finally:
if win32api:
try:
win32api.CloseHandle(job)
except Exception:
pass
job = _process_jobs.pop(process, None)
if job is not None:
_close_job_handle(job)
# Always try to terminate the process itself as well
async def terminate_windows_process_tree(process: Process | FallbackProcess) -> None:
"""Terminates the process's job, or just the process if it has no job.
Job termination is an immediate hard kill of every member. Windows has no
tree-wide SIGTERM; the stdin-close grace period is the server's chance to
exit cleanly.
"""
if sys.platform != "win32":
return
job = _process_jobs.pop(process, None)
if job is not None and win32job:
try:
with suppress(pywintypes.error): # the job might already be terminated
win32job.TerminateJobObject(job, 1)
finally:
_close_job_handle(job)
# The process may have no job (creation or assignment failed); kill it directly too.
try:
process.terminate()
except Exception:
except OSError:
pass
@deprecated(
"terminate_windows_process is deprecated and will be removed in a future version. "
"Process termination is now handled internally by the stdio_client context manager."
)
async def terminate_windows_process(process: Process | FallbackProcess):
"""Terminate a Windows process.
Note: On Windows, terminating a process with process.terminate() doesn't
always guarantee immediate process termination.
If the process does not exit within 2 seconds, process.kill() is called
to send a SIGKILL-equivalent signal.
Args:
process: The process to terminate
"""
try:
process.terminate()
with anyio.fail_after(2.0):
await process.wait()
except TimeoutError:
# Force kill if it doesn't terminate
process.kill()
def _close_job_handle(job: object) -> None:
"""Closes a Job Object handle, tolerating one that is already closed."""
if win32api and pywintypes:
with suppress(pywintypes.error):
win32api.CloseHandle(job)
+1234 -394
View File
File diff suppressed because it is too large Load Diff
+37 -32
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 serverclient 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()
@@ -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
+24 -31
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.
+7 -7
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):
+108 -31
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={})
-10
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
+80
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}"
+77
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)
+276
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()
+116
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"
+235
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={})