|
2 | 2 | Windows-specific functionality for stdio client operations.
|
3 | 3 | """
|
4 | 4 |
|
| 5 | +import os |
5 | 6 | import shutil
|
| 7 | +import signal |
6 | 8 | import subprocess
|
7 | 9 | import sys
|
8 | 10 | from pathlib import Path
|
@@ -76,8 +78,29 @@ async def __aexit__(
|
76 | 78 | exc_tb: object | None,
|
77 | 79 | ) -> None:
|
78 | 80 | """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 |
81 | 104 |
|
82 | 105 | # Close the file handles to prevent ResourceWarning
|
83 | 106 | if self.stdin:
|
@@ -163,20 +186,36 @@ async def create_windows_process(
|
163 | 186 |
|
164 | 187 | async def terminate_windows_process(process: Process | FallbackProcess):
|
165 | 188 | """
|
166 |
| - Terminate a Windows process. |
| 189 | + Terminate a Windows process gracefully. |
167 | 190 |
|
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. |
172 | 194 |
|
173 | 195 | Args:
|
174 | 196 | process: The process to terminate
|
175 | 197 | """
|
| 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 |
176 | 211 | try:
|
177 | 212 | process.terminate()
|
178 | 213 | with anyio.fail_after(2.0):
|
179 | 214 | await process.wait()
|
180 | 215 | except TimeoutError:
|
181 | 216 | # 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