1- """The stdio transport: one subprocess end-to-end test and one in-process framing test .
1+ """Stdio subprocess round-trip and in-process framing tests .
22
3- The subprocess test proves the client-server round trip over the transport's real process
4- boundary; its server lives in `_stdio_server.py` and is launched via `python -m` so subprocess
5- coverage measurement applies. The framing test drives `stdio_server` over injected in-process
6- streams instead.
7-
8- stdio is deliberately not a leg of the `connect`-fixture matrix: a subprocess per test would be
9- slow, and the matrix already proves transport-agnosticism in-process. Process-lifecycle edge
10- cases (terminate/kill escalation, parse errors) stay in `tests/client/test_stdio.py`.
3+ Lifecycle edge cases remain in `tests/client/test_stdio.py`.
114"""
125
136import io
5043async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess (
5144 monkeypatch : pytest .MonkeyPatch ,
5245) -> None :
53- """A stdio-subprocess Client round-trips a tool call, a notification, and a clean exit.
54-
55- The Client initializes, calls a tool with arguments, and receives the server's log
56- notification before the call returns; the server exits when the transport closes its
57- stdin.
58- """
59- # After stdin closes, the child must unwind, flush its subprocess coverage data, and write
60- # the clean-exit line before escalation (the server saves coverage *before* printing, so a
61- # post-print kill can no longer silently lose the data file -- see _stdio_server.main). The
62- # production 2s default is too tight for the unwind+save tail on loaded Windows runners
63- # (measured in-situ p99 of the whole test is ~7s); a kill before the print fails the stderr
64- # assertion below loudly rather than tripping the coverage gate. The 20s grace covers even a
65- # badly starved runner (a >10s stall has been seen once in CI) and costs nothing when the
66- # child exits promptly. Not under test.
46+ """A stdio client round-trips a tool call and notification before clean exit."""
47+ # Allow slow Windows runners to flush subprocess coverage before escalation.
6748 monkeypatch .setattr (stdio , "PROCESS_TERMINATION_TIMEOUT" , 20.0 )
6849
6950 received : list [LoggingMessageNotificationParams ] = []
@@ -77,10 +58,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None:
7758 command = sys .executable ,
7859 args = ["-m" , _stdio_server .__name__ ],
7960 cwd = str (_REPO_ROOT ),
80- # stdio_client filters the inherited environment, dropping the variables
81- # coverage.py's subprocess support uses; pass them through so the server module is
82- # measured. PYTHONWARNINGS: the child recompiles anyio (pytest's pyc tag differs),
83- # and on 3.14 anyio's return-in-finally SyntaxWarning would land on the snapshot stderr.
61+ # Preserve subprocess coverage and suppress anyio's `SyntaxWarning` on Python 3.14.
8462 env = {key : value for key , value in os .environ .items () if key .startswith ("COVERAGE_" )}
8563 | {"PYTHONWARNINGS" : "ignore::SyntaxWarning" },
8664 ),
@@ -98,26 +76,18 @@ async def collect(params: LoggingMessageNotificationParams) -> None:
9876 captured_stderr = errlog .read ()
9977
10078 assert result == snapshot (CallToolResult (content = [TextContent (text = "across\n processes" )]))
101- # stdio carries one ordered server-to-client stream, so the same notification-before-response
102- # guarantee holds here as for the in-memory transport.
79+ # Stdio preserves notification-before-response ordering.
10380 assert received == snapshot (
10481 [LoggingMessageNotificationParams (level = "info" , logger = "echo" , data = "echoing across\n processes" )]
10582 )
106- # The server writes this line only after its run loop returns on stdin close: seeing it proves
107- # a self-exit, not the terminate escalation. The capture itself proves stderr passthrough.
83+ # The marker distinguishes clean exit from termination.
10884 assert captured_stderr == snapshot ("stdio-echo: clean exit\n " )
10985
11086
11187@requirement ("transport:stdio:stream-purity" )
11288@requirement ("transport:stdio:no-embedded-newlines" )
11389async def test_stdio_server_writes_one_jsonrpc_message_per_line () -> None :
114- """Every `stdio_server` write is one valid JSON-RPC message on its own line.
115-
116- Each line is newline-terminated with payload newlines JSON-escaped. This proves the
117- transport's own framing over injected streams; the descriptor-level guard that keeps
118- handler code off the wire is pinned by tests/server/test_stdio.py (see the narrowed
119- divergence on `transport:stdio:stream-purity`).
120- """
90+ """Each `stdio_server` write is one newline-terminated JSON-RPC message."""
12191 captured = io .StringIO ()
12292 sent_line = json .dumps (initialize_body (request_id = 1 )) + "\n "
12393
@@ -148,7 +118,5 @@ async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
148118 assert len (lines ) == 2
149119 messages = [jsonrpc_message_adapter .validate_json (line ) for line in lines ]
150120 assert [type (message ).__name__ for message in messages ] == snapshot (["JSONRPCResponse" , "JSONRPCNotification" ])
151- # The newline inside the payload is JSON-escaped on the wire, not a literal newline that would
152- # break the one-message-per-line framing.
153121 assert r"line\nbreak" in lines [0 ]
154122 assert r"two\nlines" in lines [1 ]
0 commit comments