|
6 | 6 | import subprocess
|
7 | 7 | import sys
|
8 | 8 | from pathlib import Path
|
9 |
| -from typing import TextIO |
| 9 | +from typing import BinaryIO, TextIO, cast |
10 | 10 |
|
11 | 11 | import anyio
|
| 12 | +from anyio import to_thread |
12 | 13 | from anyio.abc import Process
|
| 14 | +from anyio.streams.file import FileReadStream, FileWriteStream |
13 | 15 |
|
14 | 16 |
|
15 | 17 | def get_windows_executable_command(command: str) -> str:
|
@@ -44,49 +46,122 @@ def get_windows_executable_command(command: str) -> str:
|
44 | 46 | return command
|
45 | 47 |
|
46 | 48 |
|
| 49 | +class DummyProcess: |
| 50 | + """ |
| 51 | + A fallback process wrapper for Windows to handle async I/O |
| 52 | + when using subprocess.Popen, which provides sync-only FileIO objects. |
| 53 | +
|
| 54 | + This wraps stdin and stdout into async-compatible |
| 55 | + streams (FileReadStream, FileWriteStream), |
| 56 | + so that MCP clients expecting async streams can work properly. |
| 57 | + """ |
| 58 | + |
| 59 | + def __init__(self, popen_obj: subprocess.Popen[bytes]): |
| 60 | + self.popen: subprocess.Popen[bytes] = popen_obj |
| 61 | + self.stdin_raw = popen_obj.stdin # type: ignore[assignment] |
| 62 | + self.stdout_raw = popen_obj.stdout # type: ignore[assignment] |
| 63 | + self.stderr = popen_obj.stderr # type: ignore[assignment] |
| 64 | + |
| 65 | + self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None |
| 66 | + self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None |
| 67 | + |
| 68 | + async def __aenter__(self): |
| 69 | + """Support async context manager entry.""" |
| 70 | + return self |
| 71 | + |
| 72 | + async def __aexit__( |
| 73 | + self, |
| 74 | + exc_type: BaseException | None, |
| 75 | + exc_val: BaseException | None, |
| 76 | + exc_tb: object | None, |
| 77 | + ) -> None: |
| 78 | + """Terminate and wait on process exit inside a thread.""" |
| 79 | + self.popen.terminate() |
| 80 | + await to_thread.run_sync(self.popen.wait) |
| 81 | + |
| 82 | + # Close the file handles to prevent ResourceWarning |
| 83 | + if self.stdin: |
| 84 | + await self.stdin.aclose() |
| 85 | + if self.stdout: |
| 86 | + await self.stdout.aclose() |
| 87 | + if self.stdin_raw: |
| 88 | + self.stdin_raw.close() |
| 89 | + if self.stdout_raw: |
| 90 | + self.stdout_raw.close() |
| 91 | + if self.stderr: |
| 92 | + self.stderr.close() |
| 93 | + |
| 94 | + async def wait(self): |
| 95 | + """Async wait for process completion.""" |
| 96 | + return await to_thread.run_sync(self.popen.wait) |
| 97 | + |
| 98 | + def terminate(self): |
| 99 | + """Terminate the subprocess immediately.""" |
| 100 | + return self.popen.terminate() |
| 101 | + |
| 102 | + def kill(self) -> None: |
| 103 | + """Kill the subprocess immediately (alias for terminate).""" |
| 104 | + self.terminate() |
| 105 | + |
| 106 | + |
| 107 | +# ------------------------ |
| 108 | +# Updated function |
| 109 | +# ------------------------ |
| 110 | + |
| 111 | + |
47 | 112 | async def create_windows_process(
|
48 | 113 | command: str,
|
49 | 114 | args: list[str],
|
50 | 115 | env: dict[str, str] | None = None,
|
51 |
| - errlog: TextIO = sys.stderr, |
| 116 | + errlog: TextIO | None = sys.stderr, |
52 | 117 | cwd: Path | str | None = None,
|
53 |
| -): |
| 118 | +) -> DummyProcess: |
54 | 119 | """
|
55 | 120 | Creates a subprocess in a Windows-compatible way.
|
56 | 121 |
|
57 |
| - Windows processes need special handling for console windows and |
58 |
| - process creation flags. |
| 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. |
59 | 125 |
|
60 | 126 | Args:
|
61 |
| - command: The command to execute |
62 |
| - args: Command line arguments |
63 |
| - env: Environment variables |
64 |
| - errlog: Where to send stderr output |
65 |
| - cwd: Working directory for the process |
| 127 | + command (str): The executable to run |
| 128 | + args (list[str]): List of command line arguments |
| 129 | + env (dict[str, str] | None): Environment variables |
| 130 | + errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) |
| 131 | + cwd (Path | str | None): Working directory for the subprocess |
66 | 132 |
|
67 | 133 | Returns:
|
68 |
| - A process handle |
| 134 | + DummyProcess: Async-compatible subprocess with stdin and stdout streams |
69 | 135 | """
|
70 | 136 | try:
|
71 |
| - # Try with Windows-specific flags to hide console window |
72 |
| - process = await anyio.open_process( |
| 137 | + # Try launching with creationflags to avoid opening a new console window |
| 138 | + popen_obj = subprocess.Popen( |
73 | 139 | [command, *args],
|
74 |
| - env=env, |
75 |
| - # Ensure we don't create console windows for each process |
76 |
| - creationflags=subprocess.CREATE_NO_WINDOW # type: ignore |
77 |
| - if hasattr(subprocess, "CREATE_NO_WINDOW") |
78 |
| - else 0, |
| 140 | + stdin=subprocess.PIPE, |
| 141 | + stdout=subprocess.PIPE, |
79 | 142 | stderr=errlog,
|
| 143 | + env=env, |
80 | 144 | cwd=cwd,
|
| 145 | + bufsize=0, # Unbuffered output |
| 146 | + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), |
81 | 147 | )
|
82 |
| - return process |
| 148 | + return DummyProcess(popen_obj) |
| 149 | + |
83 | 150 | except Exception:
|
84 |
| - # Don't raise, let's try to create the process without creation flags |
85 |
| - process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) |
86 |
| - return process |
| 151 | + # If creationflags failed, fallback without them |
| 152 | + popen_obj = subprocess.Popen( |
| 153 | + [command, *args], |
| 154 | + stdin=subprocess.PIPE, |
| 155 | + stdout=subprocess.PIPE, |
| 156 | + stderr=errlog, |
| 157 | + env=env, |
| 158 | + cwd=cwd, |
| 159 | + bufsize=0, |
| 160 | + ) |
| 161 | + return DummyProcess(popen_obj) |
87 | 162 |
|
88 | 163 |
|
89 |
| -async def terminate_windows_process(process: Process): |
| 164 | +async def terminate_windows_process(process: Process | DummyProcess): |
90 | 165 | """
|
91 | 166 | Terminate a Windows process.
|
92 | 167 |
|
|
0 commit comments