Skip to content

Commit 863dd54

Browse files
Simplify stdio cleanup: unified stream cleanup across platforms
cherry-pick of #555 Add regression tests for stdio cleanup hanging **NOTE: These tests FAIL without the changes introduced in #559** - test_stdio_client_universal_timeout - test_stdio_client_immediate_completion await read_stream.aclose() await write_stream.aclose() await read_stream_writer.aclose() await write_stream_reader.aclose() These tests verify that stdio_client completes cleanup within reasonable time for both slow-terminating and fast-exiting processes, preventing the hanging issues reported in #559. **NOTE: This test FAILS without the changes introduced in #555** - test_stdio_client_sigint_only_process try: process.terminate() with anyio.fail_after(2.0): await process.wait() except TimeoutError: # If process doesn't terminate in time, force kill it process.kill() This test verifies that on UNIX systems MCP servers that don't respect SIGTERM but e.g. SIGINT still get terminated after a grace period.
1 parent 6f43d1f commit 863dd54

File tree

3 files changed

+123
-28
lines changed

3 files changed

+123
-28
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from .win32 import (
1717
create_windows_process,
1818
get_windows_executable_command,
19-
terminate_windows_process,
2019
)
2120

2221
# Environment variables to inherit by default
@@ -180,10 +179,12 @@ async def stdin_writer():
180179
finally:
181180
# Clean up process to prevent any dangling orphaned processes
182181
try:
183-
if sys.platform == "win32":
184-
await terminate_windows_process(process)
185-
else:
186-
process.terminate()
182+
process.terminate()
183+
with anyio.fail_after(2.0):
184+
await process.wait()
185+
except TimeoutError:
186+
# If process doesn't terminate in time, force kill it
187+
process.kill()
187188
except ProcessLookupError:
188189
# Process already exited, which is fine
189190
pass

src/mcp/client/stdio/win32.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
from pathlib import Path
99
from typing import BinaryIO, TextIO, cast
1010

11-
import anyio
1211
from anyio import to_thread
13-
from anyio.abc import Process
1412
from anyio.streams.file import FileReadStream, FileWriteStream
1513

1614

@@ -159,24 +157,3 @@ async def create_windows_process(
159157
bufsize=0,
160158
)
161159
return FallbackProcess(popen_obj)
162-
163-
164-
async def terminate_windows_process(process: Process | FallbackProcess):
165-
"""
166-
Terminate a Windows process.
167-
168-
Note: On Windows, terminating a process with process.terminate() doesn't
169-
always guarantee immediate process termination.
170-
So we give it 2s to exit, or we call process.kill()
171-
which sends a SIGKILL equivalent signal.
172-
173-
Args:
174-
process: The process to terminate
175-
"""
176-
try:
177-
process.terminate()
178-
with anyio.fail_after(2.0):
179-
await process.wait()
180-
except TimeoutError:
181-
# Force kill if it doesn't terminate
182-
process.kill()

tests/client/test_stdio.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import shutil
2+
import sys
3+
import textwrap
4+
import time
25

6+
import anyio
37
import pytest
48

59
from mcp.client.session import ClientSession
@@ -90,3 +94,116 @@ async def test_stdio_client_nonexistent_command():
9094
or "not found" in error_message.lower()
9195
or "cannot find the file" in error_message.lower() # Windows error message
9296
)
97+
98+
99+
@pytest.mark.anyio
100+
async def test_stdio_client_universal_cleanup():
101+
"""
102+
Test that stdio_client completes cleanup within reasonable time
103+
even when connected to processes that exit slowly.
104+
"""
105+
106+
# Use a simple sleep command that's available on all platforms
107+
# This simulates a process that takes time to terminate
108+
if sys.platform == "win32":
109+
# Windows: use ping with timeout to simulate a running process
110+
server_params = StdioServerParameters(
111+
command="ping",
112+
args=["127.0.0.1", "-n", "10"], # Ping 10 times, takes ~10 seconds
113+
)
114+
else:
115+
# Unix: use sleep command
116+
server_params = StdioServerParameters(
117+
command="sleep",
118+
args=["10"], # Sleep for 10 seconds
119+
)
120+
121+
start_time = time.time()
122+
123+
# Use move_on_after which is more reliable for cleanup scenarios
124+
with anyio.move_on_after(6.0) as cancel_scope:
125+
async with stdio_client(server_params) as (read_stream, write_stream):
126+
# Immediately exit - this triggers cleanup while process is still running
127+
pass
128+
129+
end_time = time.time()
130+
elapsed = end_time - start_time
131+
132+
# Key assertion: Should complete quickly due to timeout mechanism
133+
assert elapsed < 5.0, (
134+
f"stdio_client cleanup took {elapsed:.1f} seconds, expected < 5.0 seconds. "
135+
f"This suggests the timeout mechanism may not be working properly."
136+
)
137+
138+
# Check if we timed out
139+
if cancel_scope.cancelled_caught:
140+
pytest.fail(
141+
"stdio_client cleanup timed out after 6.0 seconds. "
142+
"This indicates the cleanup mechanism is hanging and needs fixing."
143+
)
144+
145+
146+
@pytest.mark.anyio
147+
@pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different")
148+
async def test_stdio_client_sigint_only_process():
149+
"""
150+
Test cleanup with a process that ignores SIGTERM but responds to SIGINT.
151+
"""
152+
# Create a Python script that ignores SIGTERM but handles SIGINT
153+
script_content = textwrap.dedent(
154+
"""
155+
import signal
156+
import sys
157+
import time
158+
159+
# Ignore SIGTERM (what process.terminate() sends)
160+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
161+
162+
# Handle SIGINT (Ctrl+C signal) by exiting cleanly
163+
def sigint_handler(signum, frame):
164+
sys.exit(0)
165+
166+
signal.signal(signal.SIGINT, sigint_handler)
167+
168+
# Keep running until SIGINT received
169+
while True:
170+
time.sleep(0.1)
171+
"""
172+
)
173+
174+
server_params = StdioServerParameters(
175+
command="python",
176+
args=["-c", script_content],
177+
)
178+
179+
start_time = time.time()
180+
181+
try:
182+
# Use anyio timeout to prevent test from hanging forever
183+
with anyio.move_on_after(5.0) as cancel_scope:
184+
async with stdio_client(server_params) as (read_stream, write_stream):
185+
# Let the process start and begin ignoring SIGTERM
186+
await anyio.sleep(0.5)
187+
# Exit context triggers cleanup - this should not hang
188+
pass
189+
190+
if cancel_scope.cancelled_caught:
191+
raise TimeoutError("Test timed out")
192+
193+
end_time = time.time()
194+
elapsed = end_time - start_time
195+
196+
# Should complete quickly even with SIGTERM-ignoring process
197+
# This will fail if cleanup only uses process.terminate() without fallback
198+
assert elapsed < 5.0, (
199+
f"stdio_client cleanup took {elapsed:.1f} seconds with SIGTERM-ignoring process. "
200+
f"Expected < 5.0 seconds. This suggests the cleanup needs SIGINT/SIGKILL fallback."
201+
)
202+
except (TimeoutError, Exception) as e:
203+
if isinstance(e, TimeoutError) or "timed out" in str(e):
204+
pytest.fail(
205+
"stdio_client cleanup timed out after 5.0 seconds with SIGTERM-ignoring process. "
206+
"This confirms the cleanup needs SIGINT/SIGKILL fallback for processes that ignore SIGTERM."
207+
)
208+
else:
209+
raise

0 commit comments

Comments
 (0)