Unify process termination on POSIX & Windows (+ tests) (#1044)

Co-authored-by: Cristian Pufu <cristian.pufu@uipath.com>
This commit is contained in:
Felix Weinberger 2025-07-08 14:57:39 +01:00 • committed by GitHub
commit cf72565d9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 187 additions and 9 deletions

View file

@ -16,7 +16,6 @@ from mcp.shared.message import SessionMessage
from .win32 import (
create_windows_process,
get_windows_executable_command,
terminate_windows_process,
)
# Environment variables to inherit by default
@ -38,6 +37,9 @@ DEFAULT_INHERITED_ENV_VARS = (
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
)
# Timeout for process termination before falling back to force kill
PROCESS_TERMINATION_TIMEOUT = 2.0
def get_default_environment() -> dict[str, str]:
"""
@ -180,10 +182,12 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
finally:
# Clean up process to prevent any dangling orphaned processes
try:
if sys.platform == "win32":
await terminate_windows_process(process)
else:
process.terminate()
process.terminate()
with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
await process.wait()
except TimeoutError:
# If process doesn't terminate in time, force kill it
process.kill()
except ProcessLookupError:
# Process already exited, which is fine
pass

View file

@ -12,6 +12,7 @@ import anyio
from anyio import to_thread
from anyio.abc import Process
from anyio.streams.file import FileReadStream, FileWriteStream
from typing_extensions import deprecated
def get_windows_executable_command(command: str) -> str:
@ -115,13 +116,14 @@ async def create_windows_process(
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> FallbackProcess:
) -> Process | FallbackProcess:
"""
Creates a subprocess in a Windows-compatible way.
On Windows, asyncio.create_subprocess_exec has incomplete support
(NotImplementedError when trying to open subprocesses).
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
Attempt to use anyio's open_process for async subprocess creation.
In some cases this will throw NotImplementedError on Windows, e.g.
when using the SelectorEventLoop which does not support async subprocesses.
In that case, we fall back to using subprocess.Popen.
Args:
command (str): The executable to run
@ -133,6 +135,45 @@ async def create_windows_process(
Returns:
FallbackProcess: Async-compatible subprocess with stdin and stdout streams
"""
try:
# First try using anyio with Windows-specific flags to hide console window
process = await anyio.open_process(
[command, *args],
env=env,
# Ensure we don't create console windows for each process
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
if hasattr(subprocess, "CREATE_NO_WINDOW")
else 0,
stderr=errlog,
cwd=cwd,
)
return process
except NotImplementedError:
# Windows often doesn't support async subprocess creation, use fallback
return await _create_windows_fallback_process(command, args, env, errlog, cwd)
except Exception:
# Try again without creation flags
process = await anyio.open_process(
[command, *args],
env=env,
stderr=errlog,
cwd=cwd,
)
return process
async def _create_windows_fallback_process(
command: str,
args: list[str],
env: dict[str, str] | None = None,
errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None,
) -> FallbackProcess:
"""
Create a subprocess using subprocess.Popen as a fallback when anyio fails.
This function wraps the sync subprocess.Popen in an async-compatible interface.
"""
try:
# Try launching with creationflags to avoid opening a new console window
popen_obj = subprocess.Popen(
@ -161,6 +202,10 @@ async def create_windows_process(
return FallbackProcess(popen_obj)
@deprecated(
"terminate_windows_process is deprecated and will be removed in a future version. "
"Process termination is now handled internally by the stdio_client context manager."
)
async def terminate_windows_process(process: Process | FallbackProcess):
"""
Terminate a Windows process.