Fix Windows subprocess NotImplementedError (STDIO clients) (#596)
Merging, rename for `DummyProcess` -> `FallbackProcess` coming in https://github.com/modelcontextprotocol/python-sdk/pull/1015
This commit is contained in:
parent
679b22970e
commit
ecc916516d
1 changed files with 98 additions and 23 deletions
|
|
@ -6,10 +6,12 @@ import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TextIO
|
from typing import BinaryIO, TextIO, cast
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
from anyio import to_thread
|
||||||
from anyio.abc import Process
|
from anyio.abc import Process
|
||||||
|
from anyio.streams.file import FileReadStream, FileWriteStream
|
||||||
|
|
||||||
|
|
||||||
def get_windows_executable_command(command: str) -> str:
|
def get_windows_executable_command(command: str) -> str:
|
||||||
|
|
@ -44,49 +46,122 @@ def get_windows_executable_command(command: str) -> str:
|
||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
class DummyProcess:
|
||||||
|
"""
|
||||||
|
A fallback process wrapper for Windows to handle async I/O
|
||||||
|
when using subprocess.Popen, which provides sync-only FileIO objects.
|
||||||
|
|
||||||
|
This wraps stdin and stdout into async-compatible
|
||||||
|
streams (FileReadStream, FileWriteStream),
|
||||||
|
so that MCP clients expecting async streams can work properly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, popen_obj: subprocess.Popen[bytes]):
|
||||||
|
self.popen: subprocess.Popen[bytes] = popen_obj
|
||||||
|
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
|
||||||
|
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
|
||||||
|
self.stderr = popen_obj.stderr # type: ignore[assignment]
|
||||||
|
|
||||||
|
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
|
||||||
|
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Support async context manager entry."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: BaseException | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: object | None,
|
||||||
|
) -> None:
|
||||||
|
"""Terminate and wait on process exit inside a thread."""
|
||||||
|
self.popen.terminate()
|
||||||
|
await to_thread.run_sync(self.popen.wait)
|
||||||
|
|
||||||
|
# Close the file handles to prevent ResourceWarning
|
||||||
|
if self.stdin:
|
||||||
|
await self.stdin.aclose()
|
||||||
|
if self.stdout:
|
||||||
|
await self.stdout.aclose()
|
||||||
|
if self.stdin_raw:
|
||||||
|
self.stdin_raw.close()
|
||||||
|
if self.stdout_raw:
|
||||||
|
self.stdout_raw.close()
|
||||||
|
if self.stderr:
|
||||||
|
self.stderr.close()
|
||||||
|
|
||||||
|
async def wait(self):
|
||||||
|
"""Async wait for process completion."""
|
||||||
|
return await to_thread.run_sync(self.popen.wait)
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
"""Terminate the subprocess immediately."""
|
||||||
|
return self.popen.terminate()
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
"""Kill the subprocess immediately (alias for terminate)."""
|
||||||
|
self.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Updated function
|
||||||
|
# ------------------------
|
||||||
|
|
||||||
|
|
||||||
async def create_windows_process(
|
async def create_windows_process(
|
||||||
command: str,
|
command: str,
|
||||||
args: list[str],
|
args: list[str],
|
||||||
env: dict[str, str] | None = None,
|
env: dict[str, str] | None = None,
|
||||||
errlog: TextIO = sys.stderr,
|
errlog: TextIO | None = sys.stderr,
|
||||||
cwd: Path | str | None = None,
|
cwd: Path | str | None = None,
|
||||||
):
|
) -> DummyProcess:
|
||||||
"""
|
"""
|
||||||
Creates a subprocess in a Windows-compatible way.
|
Creates a subprocess in a Windows-compatible way.
|
||||||
|
|
||||||
Windows processes need special handling for console windows and
|
On Windows, asyncio.create_subprocess_exec has incomplete support
|
||||||
process creation flags.
|
(NotImplementedError when trying to open subprocesses).
|
||||||
|
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: The command to execute
|
command (str): The executable to run
|
||||||
args: Command line arguments
|
args (list[str]): List of command line arguments
|
||||||
env: Environment variables
|
env (dict[str, str] | None): Environment variables
|
||||||
errlog: Where to send stderr output
|
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
|
||||||
cwd: Working directory for the process
|
cwd (Path | str | None): Working directory for the subprocess
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A process handle
|
DummyProcess: Async-compatible subprocess with stdin and stdout streams
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try with Windows-specific flags to hide console window
|
# Try launching with creationflags to avoid opening a new console window
|
||||||
process = await anyio.open_process(
|
popen_obj = subprocess.Popen(
|
||||||
[command, *args],
|
[command, *args],
|
||||||
env=env,
|
stdin=subprocess.PIPE,
|
||||||
# Ensure we don't create console windows for each process
|
stdout=subprocess.PIPE,
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
|
|
||||||
if hasattr(subprocess, "CREATE_NO_WINDOW")
|
|
||||||
else 0,
|
|
||||||
stderr=errlog,
|
stderr=errlog,
|
||||||
|
env=env,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
|
bufsize=0, # Unbuffered output
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
)
|
)
|
||||||
return process
|
return DummyProcess(popen_obj)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't raise, let's try to create the process without creation flags
|
# If creationflags failed, fallback without them
|
||||||
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
|
popen_obj = subprocess.Popen(
|
||||||
return process
|
[command, *args],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=errlog,
|
||||||
|
env=env,
|
||||||
|
cwd=cwd,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
return DummyProcess(popen_obj)
|
||||||
|
|
||||||
|
|
||||||
async def terminate_windows_process(process: Process):
|
async def terminate_windows_process(process: Process | DummyProcess):
|
||||||
"""
|
"""
|
||||||
Terminate a Windows process.
|
Terminate a Windows process.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue