|
10 | 10 |
|
11 | 11 | import anyio
|
12 | 12 | from anyio.abc import Process
|
| 13 | +from anyio.streams.file import FileReadStream, FileWriteStream |
| 14 | + |
| 15 | +from typing import Optional, TextIO, Union |
| 16 | +from pathlib import Path |
13 | 17 |
|
14 | 18 |
|
15 | 19 | def get_windows_executable_command(command: str) -> str:
|
@@ -43,48 +47,95 @@ def get_windows_executable_command(command: str) -> str:
|
43 | 47 | # (permissions, broken symlinks, etc.)
|
44 | 48 | return command
|
45 | 49 |
|
| 50 | +class DummyProcess: |
| 51 | + """ |
| 52 | + A fallback process wrapper for Windows to handle async I/O |
| 53 | + when using subprocess.Popen, which provides sync-only FileIO objects. |
| 54 | + |
| 55 | + This wraps stdin and stdout into async-compatible streams (FileReadStream, FileWriteStream), |
| 56 | + so that MCP clients expecting async streams can work properly. |
| 57 | + """ |
| 58 | + def __init__(self, popen_obj: subprocess.Popen): |
| 59 | + self.popen = popen_obj |
| 60 | + self.stdin_raw = popen_obj.stdin |
| 61 | + self.stdout_raw = popen_obj.stdout |
| 62 | + self.stderr = popen_obj.stderr |
| 63 | + |
| 64 | + # Wrap into async-compatible AnyIO streams |
| 65 | + self.stdin = FileWriteStream(self.stdin_raw) |
| 66 | + self.stdout = FileReadStream(self.stdout_raw) |
| 67 | + |
| 68 | + async def __aenter__(self): |
| 69 | + """Support async context manager entry.""" |
| 70 | + return self |
| 71 | + |
| 72 | + async def __aexit__(self, exc_type, exc_val, exc_tb): |
| 73 | + """Terminate and wait on process exit inside a thread.""" |
| 74 | + self.popen.terminate() |
| 75 | + await anyio.to_thread.run_sync(self.popen.wait) |
| 76 | + |
| 77 | + async def wait(self): |
| 78 | + """Async wait for process completion.""" |
| 79 | + return await anyio.to_thread.run_sync(self.popen.wait) |
| 80 | + |
| 81 | + def terminate(self): |
| 82 | + """Terminate the subprocess immediately.""" |
| 83 | + return self.popen.terminate() |
| 84 | + |
| 85 | +# ------------------------ |
| 86 | +# Updated function |
| 87 | +# ------------------------ |
46 | 88 |
|
47 | 89 | async def create_windows_process(
|
48 | 90 | command: str,
|
49 | 91 | args: list[str],
|
50 |
| - env: dict[str, str] | None = None, |
51 |
| - errlog: TextIO = sys.stderr, |
52 |
| - cwd: Path | str | None = None, |
| 92 | + env: Optional[dict[str, str]] = None, |
| 93 | + errlog: Optional[TextIO] = sys.stderr, |
| 94 | + cwd: Union[Path, str, None] = None, |
53 | 95 | ):
|
54 | 96 | """
|
55 | 97 | Creates a subprocess in a Windows-compatible way.
|
56 |
| -
|
57 |
| - Windows processes need special handling for console windows and |
58 |
| - process creation flags. |
| 98 | + |
| 99 | + On Windows, asyncio.create_subprocess_exec has incomplete support |
| 100 | + (NotImplementedError when trying to open subprocesses). |
| 101 | + Therefore, we fallback to subprocess.Popen and wrap it for async usage. |
59 | 102 |
|
60 | 103 | 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 |
| 104 | + command (str): The executable to run |
| 105 | + args (list[str]): List of command line arguments |
| 106 | + env (dict[str, str] | None): Environment variables |
| 107 | + errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) |
| 108 | + cwd (Path | str | None): Working directory for the subprocess |
66 | 109 |
|
67 | 110 | Returns:
|
68 |
| - A process handle |
| 111 | + DummyProcess: Async-compatible subprocess with stdin and stdout streams |
69 | 112 | """
|
70 | 113 | try:
|
71 |
| - # Try with Windows-specific flags to hide console window |
72 |
| - process = await anyio.open_process( |
| 114 | + # Try launching with creationflags to avoid opening a new console window |
| 115 | + popen_obj = subprocess.Popen( |
73 | 116 | [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, |
| 117 | + stdin=subprocess.PIPE, |
| 118 | + stdout=subprocess.PIPE, |
79 | 119 | stderr=errlog,
|
| 120 | + env=env, |
80 | 121 | cwd=cwd,
|
| 122 | + bufsize=0, # Unbuffered output |
| 123 | + creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, |
81 | 124 | )
|
82 |
| - return process |
83 |
| - 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 |
| 125 | + return DummyProcess(popen_obj) |
87 | 126 |
|
| 127 | + except Exception: |
| 128 | + # If creationflags failed, fallback without them |
| 129 | + popen_obj = subprocess.Popen( |
| 130 | + [command, *args], |
| 131 | + stdin=subprocess.PIPE, |
| 132 | + stdout=subprocess.PIPE, |
| 133 | + stderr=errlog, |
| 134 | + env=env, |
| 135 | + cwd=cwd, |
| 136 | + bufsize=0, |
| 137 | + ) |
| 138 | + return DummyProcess(popen_obj) |
88 | 139 |
|
89 | 140 | async def terminate_windows_process(process: Process):
|
90 | 141 | """
|
|
0 commit comments