Skip to content

Commit 26461f2

Browse files
Add test demonstrating Windows cleanup issue #1027
This test shows that cleanup code after yield in MCP server lifespan is not executed on Windows when the process is terminated with process.terminate(). The test includes two scenarios: 1. Current behavior - cleanup code is unreachable on Windows 2. Proposed solution - using stdin closure for graceful shutdown The tests use pytest.xfail to mark expected failures on Windows. Github-Issue:#1027
1 parent 6f43d1f commit 26461f2

File tree

1 file changed

+261
-0
lines changed

1 file changed

+261
-0
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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 pytest
18+
19+
from mcp import ClientSession, StdioServerParameters
20+
from mcp.client.stdio import stdio_client
21+
22+
23+
@pytest.mark.anyio
24+
async def test_lifespan_cleanup_executed():
25+
"""
26+
Test that verifies cleanup code in MCP server lifespan is executed.
27+
28+
This test creates an MCP server that writes to marker files:
29+
1. When the server starts (before yield)
30+
2. When the server cleanup runs (after yield)
31+
32+
On Windows with the current implementation, the cleanup file is never created
33+
because process.terminate() kills the process immediately.
34+
"""
35+
36+
# Create marker files to track server lifecycle
37+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
38+
startup_marker = f.name
39+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
40+
cleanup_marker = f.name
41+
42+
# Remove the files so we can detect when they're created
43+
Path(startup_marker).unlink()
44+
Path(cleanup_marker).unlink()
45+
46+
# Create a minimal MCP server that tracks lifecycle
47+
server_code = textwrap.dedent(f"""
48+
import asyncio
49+
import sys
50+
from pathlib import Path
51+
from contextlib import asynccontextmanager
52+
from mcp.server.lowlevel import Server
53+
from mcp.server.stdio import stdio_server
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
64+
finally:
65+
# This cleanup code should run when server shuts down
66+
Path(CLEANUP_MARKER).write_text("cleaned up")
67+
68+
async def main():
69+
async with stdio_server() as (read, write):
70+
server = Server("test-server", version="1.0.0")
71+
server.set_lifespan(lifespan)
72+
73+
async def handle_initialize(params):
74+
return {{"protocolVersion": "1.0.0", "capabilities": {{}}}}
75+
76+
server.set_request_handler("initialize", handle_initialize)
77+
78+
async def handle_echo(params):
79+
return {{"text": params.get("text", "")}}
80+
81+
server.set_request_handler("test/echo", handle_echo)
82+
83+
await server.run(read, write)
84+
85+
if __name__ == "__main__":
86+
import asyncio
87+
asyncio.run(main())
88+
""")
89+
90+
# Write the server script to a temporary file
91+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
92+
server_script = f.name
93+
f.write(server_code)
94+
95+
try:
96+
# Launch the MCP server
97+
params = StdioServerParameters(
98+
command=sys.executable,
99+
args=[server_script]
100+
)
101+
102+
async with stdio_client(params) as (read, write):
103+
async with ClientSession(read, write) as session:
104+
# Initialize the session
105+
result = await session.initialize()
106+
assert result.protocolVersion == "1.0.0"
107+
108+
# Verify startup marker was created
109+
assert Path(startup_marker).exists(), "Server startup marker not created"
110+
assert Path(startup_marker).read_text() == "started"
111+
112+
# Make a test request to ensure server is working
113+
response = await session.send_request("test/echo", {"text": "hello"})
114+
assert response.text == "hello"
115+
116+
# Session will be closed when exiting the context manager
117+
118+
# Give server a moment to run cleanup (if it can)
119+
await asyncio.sleep(0.5)
120+
121+
# Check if cleanup marker was created
122+
if sys.platform == "win32":
123+
# On Windows, this assertion currently fails because cleanup doesn't run
124+
# When issue #1027 is fixed, this should pass
125+
pytest.xfail(
126+
"Cleanup code after yield is not executed on Windows (issue #1027)"
127+
if not Path(cleanup_marker).exists()
128+
else "Cleanup unexpectedly succeeded - issue may be fixed!"
129+
)
130+
else:
131+
# On Unix systems, cleanup should work
132+
assert Path(cleanup_marker).exists(), "Server cleanup marker not created"
133+
assert Path(cleanup_marker).read_text() == "cleaned up"
134+
135+
finally:
136+
# Clean up files
137+
for path in [server_script, startup_marker, cleanup_marker]:
138+
try:
139+
Path(path).unlink()
140+
except FileNotFoundError:
141+
pass
142+
143+
144+
@pytest.mark.anyio
145+
async def test_stdin_close_triggers_cleanup():
146+
"""
147+
Test that verifies if closing stdin allows cleanup to run.
148+
149+
This is the proposed solution from PR #1044 - close stdin first
150+
and wait for the server to exit gracefully before terminating.
151+
"""
152+
153+
# Create marker files to track server lifecycle
154+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
155+
startup_marker = f.name
156+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
157+
cleanup_marker = f.name
158+
159+
# Remove the files so we can detect when they're created
160+
Path(startup_marker).unlink()
161+
Path(cleanup_marker).unlink()
162+
163+
# Create an MCP server that handles stdin closure gracefully
164+
server_code = textwrap.dedent(f"""
165+
import asyncio
166+
import sys
167+
from pathlib import Path
168+
from contextlib import asynccontextmanager
169+
from mcp.server.lowlevel import Server
170+
from mcp.server.stdio import stdio_server
171+
172+
STARTUP_MARKER = {repr(startup_marker)}
173+
CLEANUP_MARKER = {repr(cleanup_marker)}
174+
175+
@asynccontextmanager
176+
async def lifespan(server):
177+
# Write startup marker
178+
Path(STARTUP_MARKER).write_text("started")
179+
try:
180+
yield
181+
finally:
182+
# This cleanup code should run when stdin closes
183+
Path(CLEANUP_MARKER).write_text("cleaned up")
184+
185+
async def main():
186+
try:
187+
async with stdio_server() as (read, write):
188+
server = Server("test-server", version="1.0.0")
189+
server.set_lifespan(lifespan)
190+
191+
async def handle_initialize(params):
192+
return {{"protocolVersion": "1.0.0", "capabilities": {{}}}}
193+
194+
server.set_request_handler("initialize", handle_initialize)
195+
196+
# The server should exit gracefully when stdin closes
197+
await server.run(read, write)
198+
except Exception:
199+
# Server might get EOF or other errors when stdin closes
200+
pass
201+
202+
if __name__ == "__main__":
203+
asyncio.run(main())
204+
""")
205+
206+
# Write the server script to a temporary file
207+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
208+
server_script = f.name
209+
f.write(server_code)
210+
211+
try:
212+
# This test manually manages the process to test stdin closure
213+
import anyio
214+
from mcp.client.stdio import _create_platform_compatible_process
215+
216+
# Start the server process
217+
process = await _create_platform_compatible_process(
218+
command=sys.executable,
219+
args=[server_script],
220+
env=None,
221+
stderr=None,
222+
cwd=None
223+
)
224+
225+
# Wait for server to start
226+
await asyncio.sleep(0.5)
227+
assert Path(startup_marker).exists(), "Server startup marker not created"
228+
229+
# Close stdin to signal shutdown
230+
if process.stdin:
231+
await process.stdin.aclose()
232+
233+
# Wait for process to exit gracefully
234+
try:
235+
with anyio.fail_after(2.0):
236+
await process.wait()
237+
except TimeoutError:
238+
# If it doesn't exit after stdin close, terminate it
239+
process.terminate()
240+
await process.wait()
241+
242+
# Check if cleanup ran
243+
await asyncio.sleep(0.5)
244+
245+
# This should work if the server properly handles stdin closure
246+
if Path(cleanup_marker).exists():
247+
assert Path(cleanup_marker).read_text() == "cleaned up"
248+
# If this works, it shows stdin closure can trigger graceful shutdown
249+
else:
250+
pytest.xfail(
251+
"Server did not run cleanup after stdin closure - "
252+
"may need additional server-side handling"
253+
)
254+
255+
finally:
256+
# Clean up files
257+
for path in [server_script, startup_marker, cleanup_marker]:
258+
try:
259+
Path(path).unlink()
260+
except FileNotFoundError:
261+
pass

0 commit comments

Comments
 (0)