Files
wehub-resource-sync 49b9bb6724
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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

277 lines
11 KiB
Python

"""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()