diff --git a/tests/interaction/transports/_stdio_server.py b/tests/interaction/transports/_stdio_server.py index 5977cc3e9..a6dad4772 100644 --- a/tests/interaction/transports/_stdio_server.py +++ b/tests/interaction/transports/_stdio_server.py @@ -9,6 +9,7 @@ import sys import anyio +import coverage from mcp.server import Server, ServerRequestContext from mcp.server.stdio import stdio_server @@ -54,9 +55,27 @@ async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestPa async def main() -> None: async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) + # Flush this process's coverage data before the clean-exit line below. Without this, the + # data is only written by coverage's atexit hook during interpreter teardown -- and on a + # slow Windows runner that can overrun the transport's termination grace, so the kill + # silently destroys the data file and the 100% gate trips on this module's subprocess-only + # lines. Saving here puts the write before the line the test synchronizes on: once the + # parent has seen "clean exit", the data is durably on disk and the escalation is harmless. + # Nothing measured may execute after the save (it would be unrecordable by construction), + # hence the excluded lines below. The branch is pragma'd because under coverage the + # instance always exists, and without coverage nothing is measured anyway. + cov = getattr(coverage.process_startup, "coverage", None) + if cov is not None: # pragma: no branch + # stop() is load-bearing twice over: it ends tracing, making itself the last + # recordable line, and it leaves nothing new for coverage's atexit re-save to flush -- + # so a kill landing during interpreter teardown cannot corrupt the file save() wrote + # (coverage opens it with sqlite journaling off; a torn rewrite would not roll back). + cov.stop() + cov.save() # pragma: lax no cover - untraced: stop() above already ended measurement # Reached only when the run loop exits because stdin closed; if the process were terminated - # the test's stderr capture would not see this line. - print("stdio-echo: clean exit", file=sys.stderr, flush=True) + # the test's stderr capture would not see this line. lax no cover: runs after the coverage + # save by design, so it can never appear covered. + print("stdio-echo: clean exit", file=sys.stderr, flush=True) # pragma: lax no cover if __name__ == "__main__": diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index 1f65996aa..5fa26d12e 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -56,10 +56,12 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess( notification before the call returns; the server exits when the transport closes its stdin. """ - # After stdin closes, the child must unwind, write the clean-exit line, and let coverage's - # atexit hook persist its subprocess data file before escalation. The production 2s default - # was too tight on slow Windows runners: the child was killed mid-atexit (test stayed green) - # and the silently missing data file tripped the 100% coverage gate. Not under test. + # After stdin closes, the child must unwind, flush its subprocess coverage data, and write + # the clean-exit line before escalation (the server saves coverage *before* printing, so a + # post-print kill can no longer silently lose the data file -- see _stdio_server.main). The + # production 2s default is too tight for the unwind+save tail on loaded Windows runners + # (measured in-situ p99 of the whole test is ~7s); a kill before the print fails the stderr + # assertion below loudly rather than tripping the coverage gate. Not under test. monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 10.0) received: list[LoggingMessageNotificationParams] = []