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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
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"
+240
View File
@@ -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={})