Skip to content

Commit 0eaaedc

Browse files
committed
fix: Windows process tree cleanup in client.stop() (#1132)
- Add _kill_process_tree() helper using psutil on Windows - Update stop() and force_stop() to kill child processes - Add psutil as Windows-only dependency - Add comprehensive unit tests Fixes #1132 (Bug 2 - SDK-level cleanup)
1 parent dd2dcbc commit 0eaaedc

3 files changed

Lines changed: 136 additions & 2 deletions

File tree

python/copilot/client.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import asyncio
18+
import importlib.util
1819
import inspect
1920
import os
2021
import re
@@ -69,6 +70,9 @@
6970

7071
ConnectionState = Literal["disconnected", "connecting", "connected", "error"]
7172

73+
# Check if psutil is available for process tree cleanup on Windows
74+
HAS_PSUTIL = importlib.util.find_spec("psutil") is not None
75+
7276
LogLevel = Literal["none", "error", "warning", "info", "debug", "all"]
7377

7478

@@ -803,6 +807,64 @@ def _extract_transform_callbacks(
803807
return wire_payload, callbacks
804808

805809

810+
def _kill_process_tree(process: subprocess.Popen) -> None:
811+
"""
812+
Kill a process and all its child processes.
813+
814+
On Windows, subprocess.Popen.terminate() only kills the parent process,
815+
leaving child processes (like MCP servers) running as orphans. This function
816+
ensures all descendants are terminated.
817+
818+
Args:
819+
process: The subprocess.Popen instance to terminate along with its children.
820+
821+
Note:
822+
If psutil is not available, falls back to simple terminate() which may
823+
leave child processes running on Windows.
824+
"""
825+
if not process or not process.pid:
826+
return
827+
828+
if HAS_PSUTIL and sys.platform == "win32":
829+
try:
830+
import psutil as ps # Import here to get the real exception classes
831+
832+
parent = ps.Process(process.pid)
833+
# Get all child processes recursively
834+
children = parent.children(recursive=True)
835+
836+
# Terminate children first (bottom-up)
837+
for child in children:
838+
try:
839+
child.terminate()
840+
except ps.NoSuchProcess:
841+
pass
842+
843+
# Give children a chance to terminate gracefully
844+
gone, alive = ps.wait_procs(children, timeout=3)
845+
846+
# Force kill any that didn't terminate
847+
for child in alive:
848+
try:
849+
child.kill()
850+
except ps.NoSuchProcess:
851+
pass
852+
853+
# Finally terminate the parent
854+
try:
855+
parent.terminate()
856+
except ps.NoSuchProcess:
857+
pass
858+
859+
except Exception:
860+
# If psutil fails for any reason, fall back to simple terminate
861+
process.terminate()
862+
else:
863+
# On non-Windows or without psutil, use simple terminate
864+
# (child processes typically die with parent on Unix-like systems)
865+
process.terminate()
866+
867+
806868
class CopilotClient:
807869
"""
808870
Main client for interacting with the Copilot CLI.
@@ -1124,7 +1186,7 @@ async def stop(self) -> None:
11241186

11251187
# Kill CLI process (only if we spawned it)
11261188
if self._process and not self._is_external_server:
1127-
self._process.terminate()
1189+
_kill_process_tree(self._process)
11281190
try:
11291191
self._process.wait(timeout=5)
11301192
except subprocess.TimeoutExpired:
@@ -1166,7 +1228,7 @@ async def force_stop(self) -> None:
11661228
if self._is_external_server:
11671229
self._process.terminate() # closes the TCP socket
11681230
else:
1169-
self._process.kill()
1231+
_kill_process_tree(self._process)
11701232
self._process = None
11711233
except Exception:
11721234
pass

python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
dependencies = [
2626
"python-dateutil>=2.9.0.post0",
2727
"pydantic>=2.0",
28+
"psutil>=5.9.0; sys_platform == 'win32'",
2829
]
2930

3031
[project.urls]

python/test_process_cleanup.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Process Cleanup Tests
3+
4+
Tests for ensuring child processes (like MCP servers) are properly terminated
5+
when the client stops, especially on Windows.
6+
7+
Related to issue #1132: https://github.com/github/copilot-sdk/issues/1132
8+
"""
9+
10+
import subprocess
11+
import sys
12+
from unittest.mock import MagicMock, patch
13+
14+
import pytest
15+
16+
from copilot.client import _kill_process_tree
17+
18+
19+
class TestKillProcessTree:
20+
"""Test the _kill_process_tree helper function."""
21+
22+
def test_kill_process_tree_with_none_process(self):
23+
"""Should handle None process gracefully."""
24+
_kill_process_tree(None) # Should not raise
25+
26+
def test_kill_process_tree_with_no_pid(self):
27+
"""Should handle process with no PID gracefully."""
28+
mock_process = MagicMock(spec=subprocess.Popen)
29+
mock_process.pid = None
30+
_kill_process_tree(mock_process) # Should not raise
31+
32+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
33+
@patch("copilot.client.HAS_PSUTIL", True)
34+
def test_kill_process_tree_on_windows_with_psutil(self):
35+
"""On Windows with psutil, should kill children recursively."""
36+
# We can't easily mock the import inside the function,
37+
# so this test verifies the function doesn't crash when psutil is available
38+
mock_process = MagicMock(spec=subprocess.Popen)
39+
mock_process.pid = 99999999 # Use a PID that doesn't exist
40+
41+
# Should not raise even with non-existent PID
42+
_kill_process_tree(mock_process)
43+
44+
# Verify terminate was called as fallback
45+
mock_process.terminate.assert_called_once()
46+
47+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
48+
@patch("copilot.client.HAS_PSUTIL", False)
49+
def test_kill_process_tree_on_windows_without_psutil(self):
50+
"""On Windows without psutil, should fall back to simple terminate."""
51+
mock_process = MagicMock(spec=subprocess.Popen)
52+
mock_process.pid = 1234
53+
54+
_kill_process_tree(mock_process)
55+
56+
mock_process.terminate.assert_called_once()
57+
58+
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows test")
59+
@patch("copilot.client.HAS_PSUTIL", True)
60+
def test_kill_process_tree_on_unix(self):
61+
"""On Unix-like systems, should use simple terminate (children die with parent)."""
62+
mock_process = MagicMock(spec=subprocess.Popen)
63+
mock_process.pid = 1234
64+
65+
_kill_process_tree(mock_process)
66+
67+
mock_process.terminate.assert_called_once()
68+
69+
70+
# Note: Integration tests for actual MCP server cleanup would go in e2e/
71+
# This file focuses on unit testing the helper function

0 commit comments

Comments
 (0)