Skip to content

Commit e4b79c9

Browse files
Fix Windows timeout mechanism in FallbackProcess.wait()
The FallbackProcess.wait() method was using to_thread.run_sync() with subprocess.Popen.wait(), which is a blocking call that cannot be interrupted by anyio's timeout mechanism. This caused the cleanup sequence to wait indefinitely for processes to exit, ignoring the 2-second timeout. The fix replaces the blocking wait with a polling approach that checks process status every 0.1 seconds, allowing anyio.fail_after() to properly interrupt the wait operation when the timeout expires. This ensures the MCP spec-compliant shutdown sequence works correctly: 1. Close stdin and wait up to 2 seconds 2. If process doesn't exit, call terminate() and wait up to 2 seconds 3. If still running, force kill with taskkill Reported-by: fweinberger
1 parent a32ddda commit e4b79c9

File tree

3 files changed

+8
-94
lines changed

3 files changed

+8
-94
lines changed

debug_windows_test.py

Lines changed: 0 additions & 64 deletions
This file was deleted.

src/mcp/client/stdio/__init__.py

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -185,47 +185,29 @@ async def stdin_writer():
185185
# 1. Close input stream to server
186186
# 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time
187187
# 3. Send SIGKILL if still not exited
188-
import logging
189-
logger = logging.getLogger("mcp.stdio_debug")
190-
logger.info("Starting cleanup sequence...")
191-
192188
if process.stdin:
193189
try:
194-
logger.info("Closing stdin...")
195190
await process.stdin.aclose()
196-
logger.info("stdin closed")
197-
except Exception as e:
191+
except Exception:
198192
# stdin might already be closed, which is fine
199-
logger.info(f"stdin close exception (ignored): {e}")
200193
pass
201194

202195
try:
203196
# Give the process time to exit gracefully after stdin closes
204-
logger.info("Waiting for process to exit after stdin closure...")
205197
with anyio.fail_after(2.0):
206198
await process.wait()
207-
logger.info("Process exited gracefully")
208199
except TimeoutError:
209200
# Process didn't exit from stdin closure, use our termination function
210201
# that handles child processes properly
211-
logger.info("Process didn't exit from stdin closure, terminating...")
212202
await _terminate_process_with_children(process)
213-
logger.info("Process terminated")
214203
except ProcessLookupError:
215204
# Process already exited, which is fine
216-
logger.info("Process already exited")
217205
pass
218206

219-
logger.info("Closing streams...")
220207
await read_stream.aclose()
221-
logger.info("read_stream closed")
222208
await write_stream.aclose()
223-
logger.info("write_stream closed")
224209
await read_stream_writer.aclose()
225-
logger.info("read_stream_writer closed")
226210
await write_stream_reader.aclose()
227-
logger.info("write_stream_reader closed")
228-
logger.info("Cleanup complete")
229211

230212

231213
def _get_executable_command(command: str) -> str:
@@ -283,9 +265,6 @@ async def _terminate_process_with_children(process: Process | FallbackProcess, t
283265
process: The process to terminate
284266
timeout: Time to wait for graceful termination before force killing
285267
"""
286-
import logging
287-
logger = logging.getLogger("mcp.stdio_debug")
288-
logger.info(f"_terminate_process_with_children called for process on {sys.platform}")
289268
if sys.platform != "win32":
290269
# POSIX: Kill entire process group to avoid orphaning children
291270
pid = getattr(process, "pid", None)
@@ -323,23 +302,17 @@ async def _terminate_process_with_children(process: Process | FallbackProcess, t
323302

324303
# Try graceful termination first
325304
try:
326-
logger.info(f"Calling terminate() on process with PID {pid}")
327305
process.terminate()
328-
logger.info("Waiting for process to exit after terminate()...")
329306
with anyio.fail_after(timeout):
330307
await process.wait()
331-
logger.info("Process exited after terminate()")
332308
except TimeoutError:
333309
# Force kill using taskkill for tree termination
334-
logger.info(f"Process didn't exit after terminate(), force killing with taskkill")
335-
result = await anyio.to_thread.run_sync(
310+
await anyio.to_thread.run_sync(
336311
subprocess.run,
337312
["taskkill", "/F", "/T", "/PID", str(pid)],
338313
capture_output=True,
339314
shell=False,
340315
check=False,
341316
)
342-
logger.info(f"taskkill result: returncode={result.returncode}, stdout={result.stdout}, stderr={result.stderr}")
343317
except ProcessLookupError:
344-
logger.info("Process already exited (ProcessLookupError)")
345318
pass

src/mcp/client/stdio/win32.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pathlib import Path
99
from typing import BinaryIO, TextIO, cast
1010

11+
import anyio
1112
from anyio import to_thread
1213
from anyio.streams.file import FileReadStream, FileWriteStream
1314

@@ -92,7 +93,11 @@ async def __aexit__(
9293

9394
async def wait(self):
9495
"""Async wait for process completion."""
95-
return await to_thread.run_sync(self.popen.wait)
96+
# Poll the process status instead of blocking wait
97+
# This allows anyio timeouts to work properly
98+
while self.popen.poll() is None:
99+
await anyio.sleep(0.1)
100+
return self.popen.returncode
96101

97102
def terminate(self):
98103
"""Terminate the subprocess immediately."""

0 commit comments

Comments
 (0)