chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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_types import JSONRPCRequest, JSONRPCResponse
|
||||
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from mcp.os.win32.utilities import FallbackProcess
|
||||
from mcp.shared.message import SessionMessage
|
||||
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"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _module_runner_lease() -> None:
|
||||
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
|
||||
|
||||
|
||||
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={})
|
||||
Reference in New Issue
Block a user