Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/windows_mcp/desktop/service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from windows_mcp.desktop.utils import ps_quote, ps_quote_for_xml, resolve_known_folder_guid_path
from windows_mcp.desktop.utils import (
ps_quote,
ps_quote_for_xml,
resolve_known_folder_guid_path,
run_with_graceful_timeout
)
from windows_mcp.vdm.core import (
get_all_desktops,
get_current_desktop,
Expand Down Expand Up @@ -420,7 +425,7 @@ def execute_command(self, command: str, timeout: int = 10, shell: str | None = N
args.extend(["-OutputFormat", "Text"])
args.extend(["-EncodedCommand", encoded])

result = subprocess.run(
result = run_with_graceful_timeout(
args,
stdin=subprocess.DEVNULL, # Prevent child processes from inheriting the MCP pipe stdin
capture_output=True, # No errors='ignore' - let subprocess return bytes
Expand Down
152 changes: 151 additions & 1 deletion src/windows_mcp/desktop/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""Desktop utilities. Input sanitization and text processing helpers."""

import logging
import os
import re
import signal
import subprocess

from xml.sax.saxutils import escape as xml_escape

import psutil
import pywintypes
from win32com.shell import shell

Expand All @@ -12,8 +17,13 @@
"ps_quote_for_xml",
"resolve_known_folder_guid_path",
"remove_private_use_chars",
"check_pid_exists",
"run_with_graceful_timeout",
]

logger = logging.getLogger(__name__)


def ps_quote(value: str) -> str:
"""Wrap value in PowerShell single-quoted string literal (escapes ' as '')."""
return "'" + value.replace("'", "''") + "'"
Expand All @@ -32,7 +42,7 @@ def resolve_known_folder_guid_path(path_text: str) -> str:
"""Resolve a Windows Known Folder GUID path to an absolute filesystem path.

Some Start Menu shortcuts store their target as a GUID-based path such as
``{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\msinfo32.exe``,
``{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\msinfo32.exe``,
where the leading ``{...}`` is a Known Folder ID (e.g. the Windows directory).
``Start-Process`` cannot launch these paths directly, so this function calls
``SHGetKnownFolderPath`` to resolve the GUID to its actual location.
Expand Down Expand Up @@ -72,3 +82,143 @@ def resolve_known_folder_guid_path(path_text: str) -> str:
def remove_private_use_chars(text: str) -> str:
"""Remove Unicode Private Use Area characters that may cause rendering issues."""
return _PRIVATE_USE_RE.sub('', text)


def check_pid_exists(pid: int) -> bool:
"""Check whether a process with the given PID is actively running."""
try:
proc = psutil.Process(pid)
return proc.status() not in (psutil.STATUS_DEAD, psutil.STATUS_ZOMBIE)
except (psutil.NoSuchProcess, psutil.AccessDenied):
return False


def run_with_graceful_timeout(
*popenargs,
input=None,
capture_output=False,
timeout=None,
check=False,
grace_period: float = 2.0,
**kwargs,
):
Comment on lines +96 to +104
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_with_graceful_timeout is newly added behavior with multiple branches (normal completion, initial timeout -> CTRL_BREAK, and timeout -> taskkill). The repository has existing unit tests for windows_mcp.desktop.utils, but this new helper is untested; please add Windows-gated tests that cover at least the normal path and the timeout path(s) to prevent regressions (especially around not hanging on pipe drainage).

Copilot uses AI. Check for mistakes.
"""A Windows-oriented variant migrated from ``subprocess.run``.

This helper keeps the overall calling style and behavior of
``subprocess.run``, but adapts the timeout-handling path for some
Windows-specific edge cases as described below.

Args:
*popenargs: Positional arguments to pass to ``subprocess.Popen``.
input: Data to send to stdin (if not None).
capture_output: If True, capture stdout and stderr into the returned CompletedProcess.
timeout: Seconds to wait for process to complete before triggering shutdown.
check: If True, raise CalledProcessError if the process exits with a non-zero code.
grace_period: Seconds to wait after CTRL_BREAK before force-killing. Defaults to 2.0.

Notes:
In some Windows scenarios, especially when launching a console host
such as PowerShell and letting it start another interactive console
process or a process stuck in an infinite loop that continuously outputs data
(for example ``pwsh -> python``, like ``pwsh -NoProfile -Command python``
or ``pwsh -NoProfile -Command "python -c 'while True: print(1)'"``),
the standard timeout flow of ``subprocess.run`` may not be sufficient.
After a timeout occurs, simply terminating the top-level child process
may still leave descendant processes alive, or leave inherited pipe handles open.
As a result, the parent process can remain blocked while trying to
finish the final ``communicate()`` cleanup, and memory usage may continue to grow if
stdout/stderr are being captured.

To make this case more robust, this function changes the timeout path
into a two-stage shutdown strategy:

1. First, try a graceful stop by sending ``CTRL_BREAK_EVENT`` to the
child process group, so console applications have a chance to exit
cleanly.
2. If that still does not finish within ``grace_period``, forcefully
terminate the whole process tree via ``taskkill /T /F``.

Related issues: #124, #146
"""

if input is not None:
if kwargs.get("stdin") is not None:
raise ValueError("stdin and input arguments may not both be used.")
kwargs["stdin"] = subprocess.PIPE

if capture_output:
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
raise ValueError("stdout and stderr arguments may not be used with capture_output.")
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE

# Windows graceful-stop prerequisite: CREATE_NEW_PROCESS_GROUP is required
# so that send_signal(CTRL_BREAK_EVENT) targets the child process group
# rather than the current process (which would cause it to exit).
creationflags = kwargs.get("creationflags", 0)
creationflags |= subprocess.CREATE_NEW_PROCESS_GROUP
kwargs["creationflags"] = creationflags

with subprocess.Popen(*popenargs, **kwargs) as process:
stdout = stderr = None
try:
stdout, stderr = process.communicate(input=input, timeout=timeout)

except subprocess.TimeoutExpired as exc1:
# Try graceful shutdown first
logger.debug('Process did not exit within timeout, attempting graceful shutdown.')
try:
process.send_signal(signal.CTRL_BREAK_EVENT)
except Exception:
logger.debug('Failed to send CTRL_BREAK_EVENT, attempting to terminate process.')

try:
exc1.stdout, exc1.stderr = process.communicate(timeout=grace_period)
logger.debug("Process exited after CTRL_BREAK, re-raising original TimeoutExpired.")
exc1.add_note("Process exited after graceful CTRL_BREAK shutdown.")
raise exc1 # (1)
except subprocess.TimeoutExpired as exc2:
if exc2 is exc1: # Raised from the previous attempt (1)
# No need to try further shutdown
raise exc2

# Kill the whole tree as a last resort
logger.debug(
f"Process {process.pid} (exist: {check_pid_exists(process.pid)}) did not exit gracefully after {grace_period} seconds, killing it and all child processes..."
)
Comment on lines +186 to +188
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This debug log uses an f-string that eagerly calls check_pid_exists(process.pid) even when debug logging is disabled, which adds an unnecessary psutil.Process(...) lookup on every timeout. Prefer guarding with logger.isEnabledFor(logging.DEBUG) or logging without the extra PID-exists probe unless needed.

Suggested change
logger.debug(
f"Process {process.pid} (exist: {check_pid_exists(process.pid)}) did not exit gracefully after {grace_period} seconds, killing it and all child processes..."
)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Process %s (exist: %s) did not exit gracefully after %s seconds, killing it and all child processes...",
process.pid,
check_pid_exists(process.pid),
grace_period,
)

Copilot uses AI. Check for mistakes.
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)

try:
exc2.stdout, exc2.stderr = process.communicate(timeout=grace_period)
except subprocess.TimeoutExpired:
# Do not replace the original timeout exception
pass

exc2.add_note(
f"Process killed after failing to exit gracefully within {grace_period} seconds."
)
raise exc2
Comment on lines +202 to +205
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the grace-period communicate(timeout=grace_period) also times out, the code raises exc2, which is a new TimeoutExpired whose .timeout value reflects grace_period rather than the original timeout. This diverges from subprocess.run(...) behavior and from the docstring claim of mirroring its API. Consider always re-raising the original timeout exception (exc1) while still attaching any collected stdout/stderr/notes from the later shutdown steps.

Copilot uses AI. Check for mistakes.

except BaseException:
# Keep cleanup strategy consistent with timeout path
logger.debug('Other exception occurred, attempting to kill process...')
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
raise

retcode = process.poll()
if check and retcode:
raise subprocess.CalledProcessError(
retcode, process.args, output=stdout, stderr=stderr
)

return subprocess.CompletedProcess(process.args, retcode, stdout, stderr)
Loading