Skip to content

Commit 535fc1d

Browse files
authored
Flush the stdio subprocess's coverage data before the clean-exit line (#2840)
1 parent 7267818 commit 535fc1d

2 files changed

Lines changed: 27 additions & 6 deletions

File tree

tests/interaction/transports/_stdio_server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import sys
1010

1111
import anyio
12+
import coverage
1213

1314
from mcp.server import Server, ServerRequestContext
1415
from mcp.server.stdio import stdio_server
@@ -54,9 +55,27 @@ async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestPa
5455
async def main() -> None:
5556
async with stdio_server() as (read_stream, write_stream):
5657
await server.run(read_stream, write_stream, server.create_initialization_options())
58+
# Flush this process's coverage data before the clean-exit line below. Without this, the
59+
# data is only written by coverage's atexit hook during interpreter teardown -- and on a
60+
# slow Windows runner that can overrun the transport's termination grace, so the kill
61+
# silently destroys the data file and the 100% gate trips on this module's subprocess-only
62+
# lines. Saving here puts the write before the line the test synchronizes on: once the
63+
# parent has seen "clean exit", the data is durably on disk and the escalation is harmless.
64+
# Nothing measured may execute after the save (it would be unrecordable by construction),
65+
# hence the excluded lines below. The branch is pragma'd because under coverage the
66+
# instance always exists, and without coverage nothing is measured anyway.
67+
cov = getattr(coverage.process_startup, "coverage", None)
68+
if cov is not None: # pragma: no branch
69+
# stop() is load-bearing twice over: it ends tracing, making itself the last
70+
# recordable line, and it leaves nothing new for coverage's atexit re-save to flush --
71+
# so a kill landing during interpreter teardown cannot corrupt the file save() wrote
72+
# (coverage opens it with sqlite journaling off; a torn rewrite would not roll back).
73+
cov.stop()
74+
cov.save() # pragma: lax no cover - untraced: stop() above already ended measurement
5775
# Reached only when the run loop exits because stdin closed; if the process were terminated
58-
# the test's stderr capture would not see this line.
59-
print("stdio-echo: clean exit", file=sys.stderr, flush=True)
76+
# the test's stderr capture would not see this line. lax no cover: runs after the coverage
77+
# save by design, so it can never appear covered.
78+
print("stdio-echo: clean exit", file=sys.stderr, flush=True) # pragma: lax no cover
6079

6180

6281
if __name__ == "__main__":

tests/interaction/transports/test_stdio.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
5656
notification before the call returns; the server exits when the transport closes its
5757
stdin.
5858
"""
59-
# After stdin closes, the child must unwind, write the clean-exit line, and let coverage's
60-
# atexit hook persist its subprocess data file before escalation. The production 2s default
61-
# was too tight on slow Windows runners: the child was killed mid-atexit (test stayed green)
62-
# and the silently missing data file tripped the 100% coverage gate. Not under test.
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. Not under test.
6365
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 10.0)
6466

6567
received: list[LoggingMessageNotificationParams] = []

0 commit comments

Comments
 (0)