1+ """
2+ Test for issue #1027: The cleanup procedure after "yield" in lifespan is unreachable on Windows
3+
4+ This test demonstrates that on Windows, when an MCP server is terminated via process.terminate(),
5+ the cleanup code after yield in the lifespan context manager is not executed.
6+
7+ The issue occurs because Windows' TerminateProcess() forcefully kills the process without
8+ allowing cleanup handlers to run.
9+ """
10+
11+ import asyncio
12+ import sys
13+ import tempfile
14+ import textwrap
15+ from pathlib import Path
16+
17+ import anyio
18+ import pytest
19+
20+ from mcp import ClientSession , StdioServerParameters
21+ from mcp .client .stdio import stdio_client , _create_platform_compatible_process
22+
23+
24+ @pytest .mark .anyio
25+ async def test_lifespan_cleanup_executed ():
26+ """
27+ Test that verifies cleanup code in MCP server lifespan is executed.
28+
29+ This test creates an MCP server that writes to marker files:
30+ 1. When the server starts (before yield)
31+ 2. When the server cleanup runs (after yield)
32+
33+ On Windows with the current implementation, the cleanup file is never created
34+ because process.terminate() kills the process immediately.
35+ """
36+
37+ # Create marker files to track server lifecycle
38+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
39+ startup_marker = f .name
40+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
41+ cleanup_marker = f .name
42+
43+ # Remove the files so we can detect when they're created
44+ Path (startup_marker ).unlink ()
45+ Path (cleanup_marker ).unlink ()
46+
47+ # Create a minimal MCP server using FastMCP that tracks lifecycle
48+ server_code = textwrap .dedent (f"""
49+ import asyncio
50+ import sys
51+ from pathlib import Path
52+ from contextlib import asynccontextmanager
53+ from mcp.server.fastmcp import FastMCP
54+
55+ STARTUP_MARKER = { repr (startup_marker )}
56+ CLEANUP_MARKER = { repr (cleanup_marker )}
57+
58+ @asynccontextmanager
59+ async def lifespan(server):
60+ # Write startup marker
61+ Path(STARTUP_MARKER).write_text("started")
62+ try:
63+ yield {{"started": True}}
64+ finally:
65+ # This cleanup code should run when server shuts down
66+ Path(CLEANUP_MARKER).write_text("cleaned up")
67+
68+ mcp = FastMCP("test-server", lifespan=lifespan)
69+
70+ @mcp.tool()
71+ def echo(text: str) -> str:
72+ return text
73+
74+ if __name__ == "__main__":
75+ mcp.run()
76+ """ )
77+
78+ # Write the server script to a temporary file
79+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".py" ) as f :
80+ server_script = f .name
81+ f .write (server_code )
82+
83+ try :
84+ # Launch the MCP server
85+ params = StdioServerParameters (
86+ command = sys .executable ,
87+ args = [server_script ]
88+ )
89+
90+ async with stdio_client (params ) as (read , write ):
91+ async with ClientSession (read , write ) as session :
92+ # Initialize the session
93+ result = await session .initialize ()
94+ assert result .protocolVersion in ["2024-11-05" , "2025-06-18" ]
95+
96+ # Verify startup marker was created
97+ assert Path (startup_marker ).exists (), "Server startup marker not created"
98+ assert Path (startup_marker ).read_text () == "started"
99+
100+ # Make a test request to ensure server is working
101+ response = await session .call_tool ("echo" , {"text" : "hello" })
102+ assert response .content [0 ].text == "hello"
103+
104+ # Session will be closed when exiting the context manager
105+
106+ # Give server a moment to run cleanup (if it can)
107+ await asyncio .sleep (0.5 )
108+
109+ # Check if cleanup marker was created
110+ # This currently fails on all platforms because process.terminate()
111+ # doesn't allow cleanup code to run
112+ if not Path (cleanup_marker ).exists ():
113+ pytest .xfail (
114+ "Cleanup code after yield is not executed when process is terminated (issue #1027)"
115+ )
116+ else :
117+ # If cleanup succeeded, the issue may be fixed
118+ assert Path (cleanup_marker ).read_text () == "cleaned up"
119+
120+ finally :
121+ # Clean up files
122+ for path in [server_script , startup_marker , cleanup_marker ]:
123+ try :
124+ Path (path ).unlink ()
125+ except FileNotFoundError :
126+ pass
127+
128+
129+ @pytest .mark .anyio
130+ @pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
131+ async def test_stdin_close_triggers_cleanup ():
132+ """
133+ Test that verifies if closing stdin allows cleanup to run.
134+
135+ This is the proposed solution from PR #1044 - close stdin first
136+ and wait for the server to exit gracefully before terminating.
137+
138+ Note on Windows ResourceWarning:
139+ On Windows, we may see ResourceWarning about unclosed file descriptors.
140+ This is expected behavior because:
141+ - We're manually managing the process lifecycle
142+ - Windows file handle cleanup works differently than Unix
143+ - The warning doesn't indicate a real issue - cleanup still works
144+ We filter this warning on Windows only to avoid test noise.
145+ """
146+
147+ # Create marker files to track server lifecycle
148+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
149+ startup_marker = f .name
150+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".txt" ) as f :
151+ cleanup_marker = f .name
152+
153+ # Remove the files so we can detect when they're created
154+ Path (startup_marker ).unlink ()
155+ Path (cleanup_marker ).unlink ()
156+
157+ # Create an MCP server that handles stdin closure gracefully
158+ server_code = textwrap .dedent (f"""
159+ import asyncio
160+ import sys
161+ from pathlib import Path
162+ from contextlib import asynccontextmanager
163+ from mcp.server.fastmcp import FastMCP
164+
165+ STARTUP_MARKER = { repr (startup_marker )}
166+ CLEANUP_MARKER = { repr (cleanup_marker )}
167+
168+ @asynccontextmanager
169+ async def lifespan(server):
170+ # Write startup marker
171+ Path(STARTUP_MARKER).write_text("started")
172+ try:
173+ yield {{"started": True}}
174+ finally:
175+ # This cleanup code should run when stdin closes
176+ Path(CLEANUP_MARKER).write_text("cleaned up")
177+
178+ mcp = FastMCP("test-server", lifespan=lifespan)
179+
180+ @mcp.tool()
181+ def echo(text: str) -> str:
182+ return text
183+
184+ if __name__ == "__main__":
185+ # The server should exit gracefully when stdin closes
186+ try:
187+ mcp.run()
188+ except Exception:
189+ # Server might get EOF or other errors when stdin closes
190+ pass
191+ """ )
192+
193+ # Write the server script to a temporary file
194+ with tempfile .NamedTemporaryFile (mode = "w" , delete = False , suffix = ".py" ) as f :
195+ server_script = f .name
196+ f .write (server_code )
197+
198+ try :
199+ # This test manually manages the process to test stdin closure
200+ # Start the server process
201+ process = await _create_platform_compatible_process (
202+ command = sys .executable ,
203+ args = [server_script ],
204+ env = None ,
205+ errlog = sys .stderr ,
206+ cwd = None
207+ )
208+
209+ # Wait for server to start
210+ await asyncio .sleep (1.0 ) # Give more time on Windows
211+
212+ # Check if process is still running
213+ if hasattr (process , 'returncode' ) and process .returncode is not None :
214+ pytest .fail (f"Server process exited with code { process .returncode } " )
215+
216+ assert Path (startup_marker ).exists (), "Server startup marker not created"
217+
218+ # Close stdin to signal shutdown
219+ if process .stdin :
220+ await process .stdin .aclose ()
221+
222+ # Wait for process to exit gracefully
223+ try :
224+ with anyio .fail_after (2.0 ):
225+ await process .wait ()
226+ except TimeoutError :
227+ # If it doesn't exit after stdin close, terminate it
228+ process .terminate ()
229+ await process .wait ()
230+
231+ # Check if cleanup ran
232+ await asyncio .sleep (0.5 )
233+
234+ # This should work if the server properly handles stdin closure
235+ if Path (cleanup_marker ).exists ():
236+ assert Path (cleanup_marker ).read_text () == "cleaned up"
237+ # If this works, it shows stdin closure can trigger graceful shutdown
238+ else :
239+ pytest .xfail (
240+ "Server did not run cleanup after stdin closure - "
241+ "may need additional server-side handling"
242+ )
243+
244+ finally :
245+ # Clean up files
246+ for path in [server_script , startup_marker , cleanup_marker ]:
247+ try :
248+ Path (path ).unlink ()
249+ except FileNotFoundError :
250+ pass
0 commit comments