Skip to content

Commit 96b4059

Browse files
Add regression test for issue #1027 (#1069)
1 parent 757d154 commit 96b4059

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)