Shorten stdio test comments (#3329)
This commit is contained in:
parent
b2025ab815
commit
0d92192765
1 changed files with 62 additions and 262 deletions
|
|
@ -1,14 +1,6 @@
|
|||
"""Windows-only stdio lifecycle behaviors, against real subprocesses.
|
||||
"""Windows stdio tests for Job Object cleanup, selector fallback, and CRLF framing.
|
||||
|
||||
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.
|
||||
The test bodies are excluded because non-Windows CI also enforces coverage.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -49,43 +41,17 @@ async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_c
|
|||
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.
|
||||
"""Closing a gracefully exited server's Job Object reaps its child.
|
||||
|
||||
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).
|
||||
This differs from POSIX. Empty `terminate_calls` distinguishes cleanup from escalation.
|
||||
"""
|
||||
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".
|
||||
# Capture startup failures that xdist would otherwise hide.
|
||||
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.
|
||||
# Job membership is inherited; DEVNULL avoids a Windows CPython startup deadlock.
|
||||
server = (
|
||||
f"import socket, subprocess, sys\n"
|
||||
f"try:\n"
|
||||
|
|
@ -111,8 +77,7 @@ async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_c
|
|||
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.
|
||||
# Allow two cold interpreter starts on loaded CI.
|
||||
with anyio.fail_after(15.0):
|
||||
async with stdio_client(server_params, errlog=errlog):
|
||||
entered_at = anyio.current_time()
|
||||
|
|
@ -123,9 +88,6 @@ async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_c
|
|||
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"
|
||||
|
|
@ -137,12 +99,7 @@ async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_c
|
|||
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.
|
||||
# Keep references alive so GC cannot close the weak-keyed Job Object early.
|
||||
try:
|
||||
for stream in streams:
|
||||
await assert_stream_closed(stream)
|
||||
|
|
@ -150,38 +107,24 @@ async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_c
|
|||
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.
|
||||
# A selector loop forces `stdio_client` to use `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.
|
||||
"""`stdio_client` uses `FallbackProcess` under `SelectorEventLoop`.
|
||||
|
||||
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.
|
||||
This covers clean exit because forced shutdown can strand the fallback reader thread.
|
||||
"""
|
||||
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"
|
||||
|
|
@ -190,35 +133,24 @@ async def test_a_selector_event_loop_session_uses_the_fallback_process_and_exits
|
|||
)
|
||||
server_params = StdioServerParameters(command=sys.executable, args=["-c", server])
|
||||
|
||||
# One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
|
||||
# Allow one cold interpreter start on loaded CI.
|
||||
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.
|
||||
"""The client accepts CRLF-framed messages from a native Windows server.
|
||||
|
||||
`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.
|
||||
Text-mode Windows servers write `\\r\\n`, while the client splits on `\\n`.
|
||||
"""
|
||||
# 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.
|
||||
# `print()` emits CRLF on Windows.
|
||||
server = (
|
||||
"import json, sys\n"
|
||||
"line = sys.stdin.readline()\n"
|
||||
|
|
@ -231,13 +163,11 @@ async def test_a_native_server_emitting_crlf_line_endings_round_trips_messages()
|
|||
|
||||
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
|
||||
|
||||
# One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
|
||||
# Allow one cold interpreter start on loaded CI.
|
||||
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={})
|
||||
|
||||
|
|
@ -262,7 +192,6 @@ async def test_a_tool_spawned_python_child_with_default_stdin_completes_promptly
|
|||
|
||||
@mcp.tool()
|
||||
def run_child_bare() -> str:
|
||||
# Even without redirection the console subsystem hands the child the standard handles.
|
||||
proc = subprocess.run([sys.executable, "-c", "pass"], timeout=20)
|
||||
return str(proc.returncode)
|
||||
|
||||
|
|
@ -271,7 +200,7 @@ async def test_a_tool_spawned_python_child_with_default_stdin_completes_promptly
|
|||
)
|
||||
transport = stdio_client(StdioServerParameters(command=sys.executable, args=["-c", server]))
|
||||
|
||||
# A regression hangs forever, so the bound only has to beat "never".
|
||||
# Guard against the original deadlock.
|
||||
with anyio.fail_after(40.0):
|
||||
async with Client(transport) as client:
|
||||
result = await client.call_tool("run_child")
|
||||
|
|
|
|||
Loading…
Reference in a new issue