Skip to content

Commit cf72565

Browse files
Unify process termination on POSIX & Windows (+ tests) (#1044)
Co-authored-by: Cristian Pufu <[email protected]>
1 parent 08d364d commit cf72565

File tree

3 files changed

+187
-9
lines changed

3 files changed

+187
-9
lines changed

src/mcp/client/stdio/__init__.py

Lines changed: 9 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
@@ -38,6 +37,9 @@
3837
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
3938
)
4039

40+
# Timeout for process termination before falling back to force kill
41+
PROCESS_TERMINATION_TIMEOUT = 2.0
42+
4143

4244
def get_default_environment() -> dict[str, str]:
4345
"""
@@ -180,10 +182,12 @@ async def stdin_writer():
180182
finally:
181183
# Clean up process to prevent any dangling orphaned processes
182184
try:
183-
if sys.platform == "win32":
184-
await terminate_windows_process(process)
185-
else:
186-
process.terminate()
185+
process.terminate()
186+
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
187+
await process.wait()
188+
except TimeoutError:
189+
# If process doesn't terminate in time, force kill it
190+
process.kill()
187191
except ProcessLookupError:
188192
# Process already exited, which is fine
189193
pass

src/mcp/client/stdio/win32.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from anyio import to_thread
1313
from anyio.abc import Process
1414
from anyio.streams.file import FileReadStream, FileWriteStream
15+
from typing_extensions import deprecated
1516

1617

1718
def get_windows_executable_command(command: str) -> str:
@@ -115,13 +116,14 @@ async def create_windows_process(
115116
env: dict[str, str] | None = None,
116117
errlog: TextIO | None = sys.stderr,
117118
cwd: Path | str | None = None,
118-
) -> FallbackProcess:
119+
) -> Process | FallbackProcess:
119120
"""
120121
Creates a subprocess in a Windows-compatible way.
121122
122-
On Windows, asyncio.create_subprocess_exec has incomplete support
123-
(NotImplementedError when trying to open subprocesses).
124-
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
123+
Attempt to use anyio's open_process for async subprocess creation.
124+
In some cases this will throw NotImplementedError on Windows, e.g.
125+
when using the SelectorEventLoop which does not support async subprocesses.
126+
In that case, we fall back to using subprocess.Popen.
125127
126128
Args:
127129
command (str): The executable to run
@@ -133,6 +135,45 @@ async def create_windows_process(
133135
Returns:
134136
FallbackProcess: Async-compatible subprocess with stdin and stdout streams
135137
"""
138+
try:
139+
# First try using anyio with Windows-specific flags to hide console window
140+
process = await anyio.open_process(
141+
[command, *args],
142+
env=env,
143+
# Ensure we don't create console windows for each process
144+
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
145+
if hasattr(subprocess, "CREATE_NO_WINDOW")
146+
else 0,
147+
stderr=errlog,
148+
cwd=cwd,
149+
)
150+
return process
151+
except NotImplementedError:
152+
# Windows often doesn't support async subprocess creation, use fallback
153+
return await _create_windows_fallback_process(command, args, env, errlog, cwd)
154+
except Exception:
155+
# Try again without creation flags
156+
process = await anyio.open_process(
157+
[command, *args],
158+
env=env,
159+
stderr=errlog,
160+
cwd=cwd,
161+
)
162+
return process
163+
164+
165+
async def _create_windows_fallback_process(
166+
command: str,
167+
args: list[str],
168+
env: dict[str, str] | None = None,
169+
errlog: TextIO | None = sys.stderr,
170+
cwd: Path | str | None = None,
171+
) -> FallbackProcess:
172+
"""
173+
Create a subprocess using subprocess.Popen as a fallback when anyio fails.
174+
175+
This function wraps the sync subprocess.Popen in an async-compatible interface.
176+
"""
136177
try:
137178
# Try launching with creationflags to avoid opening a new console window
138179
popen_obj = subprocess.Popen(
@@ -161,6 +202,10 @@ async def create_windows_process(
161202
return FallbackProcess(popen_obj)
162203

163204

205+
@deprecated(
206+
"terminate_windows_process is deprecated and will be removed in a future version. "
207+
"Process termination is now handled internally by the stdio_client context manager."
208+
)
164209
async def terminate_windows_process(process: Process | FallbackProcess):
165210
"""
166211
Terminate a Windows process.

tests/client/test_stdio.py

Lines changed: 129 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
@@ -11,6 +15,11 @@
1115
from mcp.shared.message import SessionMessage
1216
from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse
1317

18+
# Timeout for cleanup of processes that ignore SIGTERM
19+
# This timeout ensures the test fails quickly if the cleanup logic doesn't have
20+
# proper fallback mechanisms (SIGINT/SIGKILL) for processes that ignore SIGTERM
21+
SIGTERM_IGNORING_PROCESS_TIMEOUT = 5.0
22+
1423
tee: str = shutil.which("tee") # type: ignore
1524
python: str = shutil.which("python") # type: ignore
1625

@@ -90,3 +99,123 @@ async def test_stdio_client_nonexistent_command():
9099
or "not found" in error_message.lower()
91100
or "cannot find the file" in error_message.lower() # Windows error message
92101
)
102+
103+
104+
@pytest.mark.anyio
105+
async def test_stdio_client_universal_cleanup():
106+
"""
107+
Test that stdio_client completes cleanup within reasonable time
108+
even when connected to processes that exit slowly.
109+
"""
110+
111+
# Use a Python script that simulates a long-running process
112+
# This ensures consistent behavior across platforms
113+
long_running_script = textwrap.dedent(
114+
"""
115+
import time
116+
import sys
117+
118+
# Simulate a long-running process
119+
for i in range(100):
120+
time.sleep(0.1)
121+
# Flush to ensure output is visible
122+
sys.stdout.flush()
123+
sys.stderr.flush()
124+
"""
125+
)
126+
127+
server_params = StdioServerParameters(
128+
command=sys.executable,
129+
args=["-c", long_running_script],
130+
)
131+
132+
start_time = time.time()
133+
134+
with anyio.move_on_after(8.0) as cancel_scope:
135+
async with stdio_client(server_params) as (read_stream, write_stream):
136+
# Immediately exit - this triggers cleanup while process is still running
137+
pass
138+
139+
end_time = time.time()
140+
elapsed = end_time - start_time
141+
142+
# On Windows: 2s (stdin wait) + 2s (terminate wait) + overhead = ~5s expected
143+
assert elapsed < 6.0, (
144+
f"stdio_client cleanup took {elapsed:.1f} seconds, expected < 6.0 seconds. "
145+
f"This suggests the timeout mechanism may not be working properly."
146+
)
147+
148+
# Check if we timed out
149+
if cancel_scope.cancelled_caught:
150+
pytest.fail(
151+
"stdio_client cleanup timed out after 8.0 seconds. "
152+
"This indicates the cleanup mechanism is hanging and needs fixing."
153+
)
154+
155+
156+
@pytest.mark.anyio
157+
@pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different")
158+
async def test_stdio_client_sigint_only_process():
159+
"""
160+
Test cleanup with a process that ignores SIGTERM but responds to SIGINT.
161+
"""
162+
# Create a Python script that ignores SIGTERM but handles SIGINT
163+
script_content = textwrap.dedent(
164+
"""
165+
import signal
166+
import sys
167+
import time
168+
169+
# Ignore SIGTERM (what process.terminate() sends)
170+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
171+
172+
# Handle SIGINT (Ctrl+C signal) by exiting cleanly
173+
def sigint_handler(signum, frame):
174+
sys.exit(0)
175+
176+
signal.signal(signal.SIGINT, sigint_handler)
177+
178+
# Keep running until SIGINT received
179+
while True:
180+
time.sleep(0.1)
181+
"""
182+
)
183+
184+
server_params = StdioServerParameters(
185+
command=sys.executable,
186+
args=["-c", script_content],
187+
)
188+
189+
start_time = time.time()
190+
191+
try:
192+
# Use anyio timeout to prevent test from hanging forever
193+
with anyio.move_on_after(5.0) as cancel_scope:
194+
async with stdio_client(server_params) as (read_stream, write_stream):
195+
# Let the process start and begin ignoring SIGTERM
196+
await anyio.sleep(0.5)
197+
# Exit context triggers cleanup - this should not hang
198+
pass
199+
200+
if cancel_scope.cancelled_caught:
201+
raise TimeoutError("Test timed out")
202+
203+
end_time = time.time()
204+
elapsed = end_time - start_time
205+
206+
# Should complete quickly even with SIGTERM-ignoring process
207+
# This will fail if cleanup only uses process.terminate() without fallback
208+
assert elapsed < SIGTERM_IGNORING_PROCESS_TIMEOUT, (
209+
f"stdio_client cleanup took {elapsed:.1f} seconds with SIGTERM-ignoring process. "
210+
f"Expected < {SIGTERM_IGNORING_PROCESS_TIMEOUT} seconds. "
211+
"This suggests the cleanup needs SIGINT/SIGKILL fallback."
212+
)
213+
except (TimeoutError, Exception) as e:
214+
if isinstance(e, TimeoutError) or "timed out" in str(e):
215+
pytest.fail(
216+
f"stdio_client cleanup timed out after {SIGTERM_IGNORING_PROCESS_TIMEOUT} seconds "
217+
"with SIGTERM-ignoring process. "
218+
"This confirms the cleanup needs SIGINT/SIGKILL fallback for processes that ignore SIGTERM."
219+
)
220+
else:
221+
raise

0 commit comments

Comments
 (0)