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