Skip to content

Commit 375fca5

Browse files
committed
fix(python): terminate owned CLI process trees on stop
Spawn SDK-owned CLI servers in an isolated process group on POSIX and use process-tree termination in stop()/force_stop() so child processes are not orphaned when the top-level launcher exits. On Windows, use taskkill /T to reap descendant node/copilot processes. Fixes #1804
1 parent 07f849a commit 375fca5

4 files changed

Lines changed: 156 additions & 34 deletions

File tree

python/copilot/_process.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Helpers for terminating SDK-spawned CLI process trees."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import signal
7+
import subprocess
8+
import sys
9+
import time
10+
11+
12+
def popen_process_group_kwargs() -> dict[str, bool]:
13+
"""Return Popen kwargs that isolate spawned CLI servers in their own process group."""
14+
if sys.platform == "win32":
15+
return {}
16+
return {"start_new_session": True}
17+
18+
19+
def _terminate_windows_process_tree(pid: int, *, force: bool) -> None:
20+
args = ["taskkill", "/T", "/PID", str(pid)]
21+
if force:
22+
args.insert(1, "/F")
23+
kwargs: dict = {
24+
"capture_output": True,
25+
"check": False,
26+
}
27+
if hasattr(subprocess, "CREATE_NO_WINDOW"):
28+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
29+
subprocess.run(args, **kwargs)
30+
31+
32+
def terminate_owned_cli_process(
33+
process: subprocess.Popen | None,
34+
*,
35+
graceful: bool = True,
36+
timeout: float = 5.0,
37+
) -> bool:
38+
"""Terminate an SDK-owned CLI process and its descendants.
39+
40+
Returns True when the process is no longer running.
41+
"""
42+
if process is None:
43+
return True
44+
45+
if process.poll() is not None:
46+
return True
47+
48+
pid = process.pid
49+
if sys.platform == "win32":
50+
_terminate_windows_process_tree(pid, force=not graceful)
51+
else:
52+
sig = signal.SIGTERM if graceful else signal.SIGKILL
53+
try:
54+
os.killpg(os.getpgid(pid), sig)
55+
except ProcessLookupError:
56+
return True
57+
except OSError:
58+
try:
59+
if graceful:
60+
process.terminate()
61+
else:
62+
process.kill()
63+
except OSError:
64+
return process.poll() is not None
65+
66+
deadline = time.monotonic() + timeout
67+
while time.monotonic() < deadline:
68+
if process.poll() is not None:
69+
return True
70+
time.sleep(0.05)
71+
72+
if graceful:
73+
return terminate_owned_cli_process(process, graceful=False, timeout=timeout)
74+
return process.poll() is not None

python/copilot/client.py

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from ._diagnostics import log_timing
3535
from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError
36+
from ._process import popen_process_group_kwargs, terminate_owned_cli_process
3637
from ._mode import (
3738
CopilotClientMode,
3839
ToolSet,
@@ -1549,30 +1550,18 @@ async def stop(self) -> None:
15491550
# never come. Once shutdown has completed (or failed) we terminate the
15501551
# child immediately and only wait to reap it.
15511552
if self._cli_process and not self._is_external_server:
1552-
poll = getattr(self._cli_process, "poll", None)
1553-
is_running = poll is None or poll() is None
1554-
if is_running:
1555-
self._cli_process.terminate()
1556-
try:
1557-
await asyncio.to_thread(
1558-
self._cli_process.wait,
1559-
timeout=_CLI_PROCESS_EXIT_TIMEOUT_SECONDS,
1553+
exited = await asyncio.to_thread(
1554+
terminate_owned_cli_process,
1555+
self._cli_process,
1556+
graceful=True,
1557+
timeout=_CLI_PROCESS_EXIT_TIMEOUT_SECONDS,
1558+
)
1559+
if not exited:
1560+
errors.append(
1561+
StopError(
1562+
message="Timed out waiting for CLI process tree to exit after kill"
15601563
)
1561-
except subprocess.TimeoutExpired:
1562-
self._cli_process.kill()
1563-
try:
1564-
await asyncio.to_thread(
1565-
self._cli_process.wait,
1566-
timeout=_CLI_PROCESS_EXIT_TIMEOUT_SECONDS,
1567-
)
1568-
except subprocess.TimeoutExpired as e:
1569-
errors.append(
1570-
StopError(
1571-
message=(
1572-
f"Timed out waiting for CLI process to exit after kill: {e}"
1573-
)
1574-
)
1575-
)
1564+
)
15761565
if self._process is self._cli_process:
15771566
self._process = None
15781567
self._cli_process = None
@@ -1618,7 +1607,12 @@ async def force_stop(self) -> None:
16181607
if self._process is not None and self._process is not self._cli_process:
16191608
self._process.terminate()
16201609
if self._cli_process is not None:
1621-
self._cli_process.kill()
1610+
await asyncio.to_thread(
1611+
terminate_owned_cli_process,
1612+
self._cli_process,
1613+
graceful=False,
1614+
timeout=_CLI_PROCESS_EXIT_TIMEOUT_SECONDS,
1615+
)
16221616
self._process = None
16231617
self._cli_process = None
16241618
except Exception:
@@ -3507,6 +3501,7 @@ async def _start_cli_server(self) -> None:
35073501
cwd=cwd,
35083502
env=env,
35093503
creationflags=creationflags,
3504+
**popen_process_group_kwargs(),
35103505
)
35113506
self._cli_process = self._process
35123507
else:
@@ -3520,6 +3515,7 @@ async def _start_cli_server(self) -> None:
35203515
cwd=cwd,
35213516
env=env,
35223517
creationflags=creationflags,
3518+
**popen_process_group_kwargs(),
35233519
)
35243520
self._cli_process = self._process
35253521
log_timing(

python/test_client.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,12 @@ async def shutdown(self, *, timeout=None):
4949
client._cli_process = process
5050
client._is_external_server = False
5151

52-
await client.stop()
52+
with patch("copilot.client.terminate_owned_cli_process", return_value=True) as terminate_tree:
53+
await client.stop()
5354

5455
assert calls == ["runtime.shutdown"]
55-
# The runtime never self-exits after runtime.shutdown (it keeps its
56-
# JSON-RPC server alive to send the response and leaves termination to
57-
# the caller), so stop() terminates the owned process. The mocked
58-
# process exits on terminate() (wait returns immediately), so we never
59-
# escalate to kill().
60-
process.terminate.assert_called_once()
61-
process.kill.assert_not_called()
56+
terminate_tree.assert_called_once()
57+
assert terminate_tree.call_args.kwargs["graceful"] is True
6258

6359
@pytest.mark.asyncio
6460
async def test_force_stop_and_external_stop_do_not_request_runtime_shutdown(self):
@@ -75,10 +71,12 @@ async def shutdown(self):
7571
force_client._cli_process = process
7672
force_client._is_external_server = False
7773

78-
await force_client.force_stop()
74+
with patch("copilot.client.terminate_owned_cli_process", return_value=True) as terminate_tree:
75+
await force_client.force_stop()
7976

8077
assert calls == []
81-
process.kill.assert_called_once()
78+
terminate_tree.assert_called_once()
79+
assert terminate_tree.call_args.kwargs["graceful"] is False
8280

8381
external_client = CopilotClient(connection=RuntimeConnection.for_uri("localhost:1234"))
8482
external_client._rpc = Mock(runtime=Runtime())

python/test_process.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Unit tests for CLI process tree termination helpers."""
2+
3+
import signal
4+
import sys
5+
from unittest.mock import Mock, patch
6+
7+
import pytest
8+
9+
from copilot._process import popen_process_group_kwargs, terminate_owned_cli_process
10+
11+
12+
class TestPopenProcessGroupKwargs:
13+
def test_returns_start_new_session_on_posix(self):
14+
with patch.object(sys, "platform", "linux"):
15+
assert popen_process_group_kwargs() == {"start_new_session": True}
16+
17+
def test_returns_empty_on_windows(self):
18+
with patch.object(sys, "platform", "win32"):
19+
assert popen_process_group_kwargs() == {}
20+
21+
22+
class TestTerminateOwnedCliProcess:
23+
def test_noop_for_none_process(self):
24+
assert terminate_owned_cli_process(None) is True
25+
26+
def test_returns_true_for_exited_process(self):
27+
process = Mock()
28+
process.poll.return_value = 0
29+
assert terminate_owned_cli_process(process) is True
30+
31+
@patch("copilot._process.time.sleep", return_value=None)
32+
@patch("copilot._process.os.killpg")
33+
@patch("copilot._process.os.getpgid", return_value=42)
34+
def test_posix_graceful_uses_killpg_sigterm(self, _getpgid, killpg, _sleep):
35+
process = Mock()
36+
process.poll.side_effect = [None, 0]
37+
process.pid = 99
38+
39+
with patch.object(sys, "platform", "linux"):
40+
assert terminate_owned_cli_process(process, graceful=True, timeout=1.0) is True
41+
42+
killpg.assert_called_once_with(42, signal.SIGTERM)
43+
44+
@patch("copilot._process.subprocess.run")
45+
def test_windows_uses_taskkill_tree(self, run):
46+
process = Mock()
47+
process.poll.side_effect = [None, 0]
48+
process.pid = 1234
49+
50+
with patch.object(sys, "platform", "win32"):
51+
assert terminate_owned_cli_process(process, graceful=True, timeout=1.0) is True
52+
53+
run.assert_called_once()
54+
assert run.call_args.args[0][:3] == ["taskkill", "/T", "/PID"]

0 commit comments

Comments
 (0)