|
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,46 +46,107 @@ 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 = ( |
| 66 | + FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None |
| 67 | + ) |
| 68 | + self.stdout = ( |
| 69 | + FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None |
| 70 | + ) |
| 71 | + |
| 72 | + async def __aenter__(self): |
| 73 | + """Support async context manager entry.""" |
| 74 | + return self |
| 75 | + |
| 76 | + async def __aexit__( |
| 77 | + self, |
| 78 | + exc_type: BaseException | None, |
| 79 | + exc_val: BaseException | None, |
| 80 | + exc_tb: object | None, |
| 81 | + ) -> None: |
| 82 | + """Terminate and wait on process exit inside a thread.""" |
| 83 | + self.popen.terminate() |
| 84 | + await to_thread.run_sync(self.popen.wait) |
| 85 | + |
| 86 | + async def wait(self): |
| 87 | + """Async wait for process completion.""" |
| 88 | + return await to_thread.run_sync(self.popen.wait) |
| 89 | + |
| 90 | + def terminate(self): |
| 91 | + """Terminate the subprocess immediately.""" |
| 92 | + return self.popen.terminate() |
| 93 | + |
| 94 | + |
| 95 | +# ------------------------ |
| 96 | +# Updated function |
| 97 | +# ------------------------ |
| 98 | + |
| 99 | + |
47 | 100 | async def create_windows_process(
|
48 | 101 | command: str,
|
49 | 102 | args: list[str],
|
50 | 103 | env: dict[str, str] | None = None,
|
51 |
| - errlog: TextIO = sys.stderr, |
| 104 | + errlog: TextIO | None = sys.stderr, |
52 | 105 | cwd: Path | str | None = None,
|
53 |
| -): |
| 106 | +) -> DummyProcess: |
54 | 107 | """
|
55 | 108 | Creates a subprocess in a Windows-compatible way.
|
56 | 109 |
|
57 |
| - Windows processes need special handling for console windows and |
58 |
| - process creation flags. |
| 110 | + On Windows, asyncio.create_subprocess_exec has incomplete support |
| 111 | + (NotImplementedError when trying to open subprocesses). |
| 112 | + Therefore, we fallback to subprocess.Popen and wrap it for async usage. |
59 | 113 |
|
60 | 114 | 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 |
| 115 | + command (str): The executable to run |
| 116 | + args (list[str]): List of command line arguments |
| 117 | + env (dict[str, str] | None): Environment variables |
| 118 | + errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) |
| 119 | + cwd (Path | str | None): Working directory for the subprocess |
66 | 120 |
|
67 | 121 | Returns:
|
68 |
| - A process handle |
| 122 | + DummyProcess: Async-compatible subprocess with stdin and stdout streams |
69 | 123 | """
|
70 | 124 | try:
|
71 |
| - # Try with Windows-specific flags to hide console window |
72 |
| - process = await anyio.open_process( |
| 125 | + # Try launching with creationflags to avoid opening a new console window |
| 126 | + popen_obj = subprocess.Popen( |
73 | 127 | [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, |
| 128 | + stdin=subprocess.PIPE, |
| 129 | + stdout=subprocess.PIPE, |
79 | 130 | stderr=errlog,
|
| 131 | + env=env, |
80 | 132 | cwd=cwd,
|
| 133 | + bufsize=0, # Unbuffered output |
| 134 | + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0), |
81 | 135 | )
|
82 |
| - return process |
| 136 | + return DummyProcess(popen_obj) |
| 137 | + |
83 | 138 | 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 |
| 139 | + # If creationflags failed, fallback without them |
| 140 | + popen_obj = subprocess.Popen( |
| 141 | + [command, *args], |
| 142 | + stdin=subprocess.PIPE, |
| 143 | + stdout=subprocess.PIPE, |
| 144 | + stderr=errlog, |
| 145 | + env=env, |
| 146 | + cwd=cwd, |
| 147 | + bufsize=0, |
| 148 | + ) |
| 149 | + return DummyProcess(popen_obj) |
87 | 150 |
|
88 | 151 |
|
89 | 152 | async def terminate_windows_process(process: Process):
|
|
0 commit comments