Skip to content

Commit 7b5ddf4

Browse files
fix: enable graceful Windows process shutdown with CTRL_C_EVENT
Implement proper graceful shutdown for Windows subprocesses by sending CTRL_C_EVENT signal before termination. This allows cleanup code in lifespan context managers to execute properly. The fix addresses issue #1027 where cleanup code after yield statements was not being executed on Windows due to forceful process termination. Changes: - Send CTRL_C_EVENT signal for graceful shutdown on Windows - Wait up to 2 seconds for process to exit gracefully - Fall back to terminate() if graceful shutdown fails - Add terminate_windows_process() helper function Github-Issue:#1027 Reported-by:Felix Weinberger
1 parent e17b13a commit 7b5ddf4

File tree

1 file changed

+47
-8
lines changed

1 file changed

+47
-8
lines changed

src/mcp/client/stdio/win32.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
Windows-specific functionality for stdio client operations.
33
"""
44

5+
import os
56
import shutil
7+
import signal
68
import subprocess
79
import sys
810
from pathlib import Path
@@ -76,8 +78,29 @@ async def __aexit__(
7678
exc_tb: object | None,
7779
) -> None:
7880
"""Terminate and wait on process exit inside a thread."""
79-
self.popen.terminate()
80-
await to_thread.run_sync(self.popen.wait)
81+
# Try graceful shutdown with CTRL_C_EVENT on Windows
82+
if sys.platform == "win32" and hasattr(signal, "CTRL_C_EVENT"):
83+
try:
84+
# Send CTRL_C_EVENT for graceful shutdown
85+
os.kill(self.popen.pid, getattr(signal, "CTRL_C_EVENT"))
86+
# Wait for process to exit gracefully (2 second timeout)
87+
await to_thread.run_sync(lambda: self.popen.wait(timeout=2))
88+
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
89+
# If graceful shutdown fails, fall back to terminate
90+
try:
91+
self.popen.terminate()
92+
await to_thread.run_sync(self.popen.wait)
93+
except ProcessLookupError:
94+
# Process already exited
95+
pass
96+
else:
97+
# Non-Windows or fallback behavior
98+
try:
99+
self.popen.terminate()
100+
await to_thread.run_sync(self.popen.wait)
101+
except ProcessLookupError:
102+
# Process already exited
103+
pass
81104

82105
# Close the file handles to prevent ResourceWarning
83106
if self.stdin:
@@ -163,20 +186,36 @@ async def create_windows_process(
163186

164187
async def terminate_windows_process(process: Process | FallbackProcess):
165188
"""
166-
Terminate a Windows process.
189+
Terminate a Windows process gracefully.
167190
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.
191+
First attempts graceful shutdown using CTRL_C_EVENT signal,
192+
which allows the process to run cleanup code.
193+
Falls back to terminate() and then kill() if graceful shutdown fails.
172194
173195
Args:
174196
process: The process to terminate
175197
"""
198+
# Try graceful shutdown with CTRL_C_EVENT first
199+
if isinstance(process, FallbackProcess) and hasattr(signal, "CTRL_C_EVENT"):
200+
try:
201+
# Send CTRL_C_EVENT for graceful shutdown
202+
os.kill(process.popen.pid, getattr(signal, "CTRL_C_EVENT"))
203+
with anyio.fail_after(2.0):
204+
await process.wait()
205+
return
206+
except (TimeoutError, ProcessLookupError, OSError):
207+
# If CTRL_C_EVENT failed or timed out, continue to forceful termination
208+
pass
209+
210+
# Fall back to terminate
176211
try:
177212
process.terminate()
178213
with anyio.fail_after(2.0):
179214
await process.wait()
180215
except TimeoutError:
181216
# Force kill if it doesn't terminate
182-
process.kill()
217+
try:
218+
process.kill()
219+
except ProcessLookupError:
220+
# Process already exited
221+
pass

0 commit comments

Comments
 (0)