Flush the stdio subprocess's coverage data before the clean-exit line (#2840)

This commit is contained in:
Max 2026-06-12 10:23:37 +01:00 • committed by GitHub
commit 535fc1d0fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 27 additions and 6 deletions

View file

@ -9,6 +9,7 @@ test-only-functions convention.
import sys
import anyio
import coverage
from mcp.server import Server, ServerRequestContext
from mcp.server.stdio import stdio_server
@ -54,9 +55,27 @@ server = Server("stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool,
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__":

View file

@ -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] = []