Skip to content

Commit 26e1e49

Browse files
Add test demonstrating issue #1027 and verifying PR #1091 fixes it
This test shows that MCP server cleanup code in lifespan doesn't run when the process is terminated, but does run when stdin is closed first (as implemented in PR #1044). The test includes: - Demonstration of current broken behavior (cleanup doesn't run) - Verification that stdin closure allows graceful shutdown - Windows-specific ResourceWarning handling - Detailed documentation of the issue and solution Github-Issue:#1027
1 parent 757d154 commit 26e1e49

File tree

3 files changed

+260
-12
lines changed

3 files changed

+260
-12
lines changed

tests/client/test_stdio.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from mcp.shared.exceptions import McpError
1818
from mcp.shared.message import SessionMessage
1919
from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
20+
from tests.shared.test_win32_utils import escape_path_for_python
2021

2122
# Timeout for cleanup of processes that ignore SIGTERM
2223
# This timeout ensures the test fails quickly if the cleanup logic doesn't have
@@ -249,12 +250,6 @@ class TestChildProcessCleanup:
249250
This is a fundamental difference between Windows and Unix process termination.
250251
"""
251252

252-
@staticmethod
253-
def _escape_path_for_python(path: str) -> str:
254-
"""Escape a file path for use in Python code strings."""
255-
# Use forward slashes which work on all platforms and don't need escaping
256-
return repr(path.replace("\\", "/"))
257-
258253
@pytest.mark.anyio
259254
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
260255
async def test_basic_child_process_cleanup(self):
@@ -280,13 +275,13 @@ async def test_basic_child_process_cleanup(self):
280275
import os
281276
282277
# Mark that parent started
283-
with open({self._escape_path_for_python(parent_marker)}, 'w') as f:
278+
with open({escape_path_for_python(parent_marker)}, 'w') as f:
284279
f.write('parent started\\n')
285280
286281
# Child script that writes continuously
287282
child_script = f'''
288283
import time
289-
with open({self._escape_path_for_python(marker_file)}, 'a') as f:
284+
with open({escape_path_for_python(marker_file)}, 'a') as f:
290285
while True:
291286
f.write(f"{time.time()}")
292287
f.flush()
@@ -381,7 +376,7 @@ async def test_nested_process_tree(self):
381376
382377
# Grandchild just writes to file
383378
grandchild_script = \"\"\"import time
384-
with open({self._escape_path_for_python(grandchild_file)}, 'a') as f:
379+
with open({escape_path_for_python(grandchild_file)}, 'a') as f:
385380
while True:
386381
f.write(f"gc {{time.time()}}")
387382
f.flush()
@@ -391,7 +386,7 @@ async def test_nested_process_tree(self):
391386
subprocess.Popen([sys.executable, '-c', grandchild_script])
392387
393388
# Child writes to its file
394-
with open({self._escape_path_for_python(child_file)}, 'a') as f:
389+
with open({escape_path_for_python(child_file)}, 'a') as f:
395390
while True:
396391
f.write(f"c {time.time()}")
397392
f.flush()
@@ -401,7 +396,7 @@ async def test_nested_process_tree(self):
401396
subprocess.Popen([sys.executable, '-c', child_script])
402397
403398
# Parent writes to its file
404-
with open({self._escape_path_for_python(parent_file)}, 'a') as f:
399+
with open({escape_path_for_python(parent_file)}, 'a') as f:
405400
while True:
406401
f.write(f"p {time.time()}")
407402
f.flush()
@@ -470,7 +465,7 @@ async def test_early_parent_exit(self):
470465
471466
# Child that continues running
472467
child_script = f'''import time
473-
with open({self._escape_path_for_python(marker_file)}, 'a') as f:
468+
with open({escape_path_for_python(marker_file)}, 'a') as f:
474469
while True:
475470
f.write(f"child {time.time()}")
476471
f.flush()
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
Regression test for issue #1027: Ensure cleanup procedures run properly during shutdown
3+
4+
Issue #1027 reported that cleanup code after "yield" in lifespan was unreachable when
5+
processes were terminated. This has been fixed by implementing the MCP spec-compliant
6+
stdio shutdown sequence that closes stdin first, allowing graceful exit.
7+
8+
These tests verify the fix continues to work correctly across all platforms.
9+
"""
10+
11+
import sys
12+
import tempfile
13+
import textwrap
14+
from pathlib import Path
15+
16+
import anyio
17+
import pytest
18+
19+
from mcp import ClientSession, StdioServerParameters
20+
from mcp.client.stdio import _create_platform_compatible_process, stdio_client
21+
from tests.shared.test_win32_utils import escape_path_for_python
22+
23+
24+
@pytest.mark.anyio
25+
async def test_lifespan_cleanup_executed():
26+
"""
27+
Regression test ensuring MCP server cleanup code runs during shutdown.
28+
29+
This test verifies that the fix for issue #1027 works correctly by:
30+
1. Starting an MCP server that writes a marker file on startup
31+
2. Shutting down the server normally via stdio_client
32+
3. Verifying the cleanup code (after yield) executed and wrote its marker file
33+
34+
The fix implements proper stdin closure before termination, giving servers
35+
time to run their cleanup handlers.
36+
"""
37+
38+
# Create marker files to track server lifecycle
39+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
40+
startup_marker = f.name
41+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
42+
cleanup_marker = f.name
43+
44+
# Remove the files so we can detect when they're created
45+
Path(startup_marker).unlink()
46+
Path(cleanup_marker).unlink()
47+
48+
# Create a minimal MCP server using FastMCP that tracks lifecycle
49+
server_code = textwrap.dedent(f"""
50+
import asyncio
51+
import sys
52+
from pathlib import Path
53+
from contextlib import asynccontextmanager
54+
from mcp.server.fastmcp import FastMCP
55+
56+
STARTUP_MARKER = {escape_path_for_python(startup_marker)}
57+
CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)}
58+
59+
@asynccontextmanager
60+
async def lifespan(server):
61+
# Write startup marker
62+
Path(STARTUP_MARKER).write_text("started")
63+
try:
64+
yield {{"started": True}}
65+
finally:
66+
# This cleanup code now runs properly during shutdown
67+
Path(CLEANUP_MARKER).write_text("cleaned up")
68+
69+
mcp = FastMCP("test-server", lifespan=lifespan)
70+
71+
@mcp.tool()
72+
def echo(text: str) -> str:
73+
return text
74+
75+
if __name__ == "__main__":
76+
mcp.run()
77+
""")
78+
79+
# Write the server script to a temporary file
80+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
81+
server_script = f.name
82+
f.write(server_code)
83+
84+
try:
85+
# Launch the MCP server
86+
params = StdioServerParameters(command=sys.executable, args=[server_script])
87+
88+
async with stdio_client(params) as (read, write):
89+
async with ClientSession(read, write) as session:
90+
# Initialize the session
91+
result = await session.initialize()
92+
assert result.protocolVersion in ["2024-11-05", "2025-06-18"]
93+
94+
# Verify startup marker was created
95+
assert Path(startup_marker).exists(), "Server startup marker not created"
96+
assert Path(startup_marker).read_text() == "started"
97+
98+
# Make a test request to ensure server is working
99+
response = await session.call_tool("echo", {"text": "hello"})
100+
assert response.content[0].type == "text"
101+
assert getattr(response.content[0], "text") == "hello"
102+
103+
# Session will be closed when exiting the context manager
104+
105+
# Give server a moment to complete cleanup
106+
with anyio.move_on_after(5.0):
107+
while not Path(cleanup_marker).exists():
108+
await anyio.sleep(0.1)
109+
110+
# Verify cleanup marker was created - this works now that stdio_client
111+
# properly closes stdin before termination, allowing graceful shutdown
112+
assert Path(cleanup_marker).exists(), "Server cleanup marker not created - regression in issue #1027 fix"
113+
assert Path(cleanup_marker).read_text() == "cleaned up"
114+
115+
finally:
116+
# Clean up files
117+
for path in [server_script, startup_marker, cleanup_marker]:
118+
try:
119+
Path(path).unlink()
120+
except FileNotFoundError:
121+
pass
122+
123+
124+
@pytest.mark.anyio
125+
@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default")
126+
async def test_stdin_close_triggers_cleanup():
127+
"""
128+
Regression test verifying the stdin-based graceful shutdown mechanism.
129+
130+
This test ensures the core fix for issue #1027 continues to work by:
131+
1. Manually managing a server process
132+
2. Closing stdin to trigger graceful shutdown
133+
3. Verifying cleanup handlers run before the process exits
134+
135+
This mimics the behavior now implemented in stdio_client's shutdown sequence.
136+
137+
Note on Windows ResourceWarning:
138+
On Windows, we may see ResourceWarning about unclosed file descriptors.
139+
This is expected behavior because:
140+
- We're manually managing the process lifecycle
141+
- Windows file handle cleanup works differently than Unix
142+
- The warning doesn't indicate a real issue - cleanup still works
143+
We filter this warning on Windows only to avoid test noise.
144+
"""
145+
146+
# Create marker files to track server lifecycle
147+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
148+
startup_marker = f.name
149+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
150+
cleanup_marker = f.name
151+
152+
# Remove the files so we can detect when they're created
153+
Path(startup_marker).unlink()
154+
Path(cleanup_marker).unlink()
155+
156+
# Create an MCP server that handles stdin closure gracefully
157+
server_code = textwrap.dedent(f"""
158+
import asyncio
159+
import sys
160+
from pathlib import Path
161+
from contextlib import asynccontextmanager
162+
from mcp.server.fastmcp import FastMCP
163+
164+
STARTUP_MARKER = {escape_path_for_python(startup_marker)}
165+
CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)}
166+
167+
@asynccontextmanager
168+
async def lifespan(server):
169+
# Write startup marker
170+
Path(STARTUP_MARKER).write_text("started")
171+
try:
172+
yield {{"started": True}}
173+
finally:
174+
# This cleanup code runs when stdin closes, enabling graceful shutdown
175+
Path(CLEANUP_MARKER).write_text("cleaned up")
176+
177+
mcp = FastMCP("test-server", lifespan=lifespan)
178+
179+
@mcp.tool()
180+
def echo(text: str) -> str:
181+
return text
182+
183+
if __name__ == "__main__":
184+
# The server should exit gracefully when stdin closes
185+
try:
186+
mcp.run()
187+
except Exception:
188+
# Server might get EOF or other errors when stdin closes
189+
pass
190+
""")
191+
192+
# Write the server script to a temporary file
193+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f:
194+
server_script = f.name
195+
f.write(server_code)
196+
197+
try:
198+
# This test manually manages the process to verify stdin-based shutdown
199+
# Start the server process
200+
process = await _create_platform_compatible_process(
201+
command=sys.executable, args=[server_script], env=None, errlog=sys.stderr, cwd=None
202+
)
203+
204+
# Wait for server to start
205+
with anyio.move_on_after(10.0):
206+
while not Path(startup_marker).exists():
207+
await anyio.sleep(0.1)
208+
209+
# Check if process is still running
210+
if hasattr(process, "returncode") and process.returncode is not None:
211+
pytest.fail(f"Server process exited with code {process.returncode}")
212+
213+
assert Path(startup_marker).exists(), "Server startup marker not created"
214+
215+
# Close stdin to signal shutdown
216+
if process.stdin:
217+
await process.stdin.aclose()
218+
219+
# Wait for process to exit gracefully
220+
try:
221+
with anyio.fail_after(5.0): # Increased from 2.0 to 5.0
222+
await process.wait()
223+
except TimeoutError:
224+
# If it doesn't exit after stdin close, terminate it
225+
process.terminate()
226+
await process.wait()
227+
228+
# Check if cleanup ran
229+
with anyio.move_on_after(5.0):
230+
while not Path(cleanup_marker).exists():
231+
await anyio.sleep(0.1)
232+
233+
# Verify the cleanup ran - stdin closure enables graceful shutdown
234+
assert Path(cleanup_marker).exists(), "Server cleanup marker not created - stdin-based shutdown failed"
235+
assert Path(cleanup_marker).read_text() == "cleaned up"
236+
237+
finally:
238+
# Clean up files
239+
for path in [server_script, startup_marker, cleanup_marker]:
240+
try:
241+
Path(path).unlink()
242+
except FileNotFoundError:
243+
pass

tests/shared/test_win32_utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Windows-specific test utilities."""
2+
3+
4+
def escape_path_for_python(path: str) -> str:
5+
"""Escape a file path for use in Python code strings.
6+
7+
Converts backslashes to forward slashes which work on all platforms
8+
and don't need escaping in Python strings.
9+
"""
10+
return repr(path.replace("\\", "/"))

0 commit comments

Comments
 (0)