Skip to content

Commit 9d34216

Browse files
committed
Improve process management and timeout handling
- Enhance process termination reliability - Improve timeout handling with graceful shutdown - Add proper async/await support - Fix process cleanup in tests - Improve test coverage
1 parent 721849e commit 9d34216

File tree

4 files changed

+162
-90
lines changed

4 files changed

+162
-90
lines changed

src/mcp_shell_server/process_manager.py

+34-17
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,13 @@ async def start_process_async(
6565
process = await self.create_process(
6666
" ".join(cmd), directory=None, timeout=timeout
6767
)
68+
process.is_running = lambda self=process: self.returncode is None # type: ignore
6869
return process
6970

70-
def start_process(
71+
async def start_process(
7172
self, cmd: List[str], timeout: Optional[int] = None
7273
) -> asyncio.subprocess.Process:
73-
"""Start a new process synchronously.
74+
"""Start a new process asynchronously.
7475
7576
Args:
7677
cmd: Command to execute as list of strings
@@ -79,8 +80,8 @@ def start_process(
7980
Returns:
8081
Process object
8182
"""
82-
process = asyncio.get_event_loop().run_until_complete(
83-
self.start_process_async(cmd, timeout)
83+
process = await self.create_process(
84+
" ".join(cmd), directory=None, timeout=timeout
8485
)
8586
process.is_running = lambda self=process: self.returncode is None # type: ignore
8687
return process
@@ -186,22 +187,38 @@ async def execute_with_timeout(
186187
"""
187188
stdin_bytes = stdin.encode() if stdin else None
188189

189-
async def communicate_with_timeout():
190+
async def _kill_process():
191+
if process.returncode is not None:
192+
return
193+
190194
try:
191-
return await process.communicate(input=stdin_bytes)
192-
except asyncio.TimeoutError:
193-
process.kill() # Kill process on timeout
194-
raise
195+
# Try graceful termination first
196+
process.terminate()
197+
for _ in range(5): # Wait up to 0.5 seconds
198+
if process.returncode is not None:
199+
return
200+
await asyncio.sleep(0.1)
201+
202+
# Force kill if still running
203+
if process.returncode is None:
204+
process.kill()
205+
await asyncio.wait_for(process.wait(), timeout=1.0)
195206
except Exception as e:
196-
try:
197-
await process.wait()
198-
except Exception:
199-
pass
200-
raise e
207+
logging.warning(f"Error killing process: {e}")
201208

202-
if timeout:
203-
return await asyncio.wait_for(communicate_with_timeout(), timeout=timeout)
204-
return await communicate_with_timeout()
209+
try:
210+
if timeout:
211+
try:
212+
return await asyncio.wait_for(
213+
process.communicate(input=stdin_bytes), timeout=timeout
214+
)
215+
except asyncio.TimeoutError:
216+
await _kill_process()
217+
raise
218+
return await process.communicate(input=stdin_bytes)
219+
except Exception as e:
220+
await _kill_process()
221+
raise e
205222

206223
async def execute_pipeline(
207224
self,

tests/test_process_manager.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the ProcessManager class."""
22

3+
import asyncio
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
@@ -13,6 +14,7 @@ def create_mock_process():
1314
process.returncode = 0
1415
process.communicate = AsyncMock(return_value=(b"output", b"error"))
1516
process.wait = AsyncMock(return_value=0)
17+
process.terminate = MagicMock()
1618
process.kill = MagicMock()
1719
return process
1820

@@ -65,16 +67,23 @@ async def test_execute_with_timeout_success(process_manager):
6567
async def test_execute_with_timeout_timeout(process_manager):
6668
"""Test executing a process that times out."""
6769
mock_proc = create_mock_process()
68-
exc = TimeoutError("Process timed out")
70+
exc = asyncio.TimeoutError("Process timed out")
6971
mock_proc.communicate.side_effect = exc
72+
mock_proc.returncode = None # プロセスがまだ実行中の状態をシミュレート
73+
74+
# プロセスの終了状態をシミュレート
75+
async def set_returncode():
76+
mock_proc.returncode = -15 # SIGTERM
77+
78+
mock_proc.wait.side_effect = set_returncode
7079

7180
with pytest.raises(TimeoutError):
7281
await process_manager.execute_with_timeout(
7382
mock_proc,
7483
timeout=1,
7584
)
7685

77-
mock_proc.kill.assert_called_once()
86+
mock_proc.terminate.assert_called_once()
7887

7988

8089
@pytest.mark.asyncio

tests/test_process_manager_macos.py

+109-70
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,149 @@
11
# tests/test_process_manager_macos.py
2-
import pytest
2+
import asyncio
33
import platform
4-
import signal
5-
import time
6-
import os
74
import subprocess
8-
from typing import Generator
5+
6+
import pytest
97

108
pytestmark = [
119
pytest.mark.skipif(
12-
platform.system() != "Darwin",
13-
reason="These tests only run on macOS"
10+
platform.system() != "Darwin", reason="These tests only run on macOS"
1411
),
1512
pytest.mark.macos,
16-
pytest.mark.slow
13+
pytest.mark.slow,
1714
]
1815

16+
1917
@pytest.fixture
2018
def process_manager():
2119
from mcp_shell_server.process_manager import ProcessManager
20+
2221
pm = ProcessManager()
23-
yield pm
24-
pm.cleanup_all()
22+
try:
23+
yield pm
24+
finally:
25+
asyncio.run(pm.cleanup_all())
26+
2527

2628
def get_process_status(pid: int) -> str:
2729
"""Get process status using ps command."""
2830
try:
2931
ps = subprocess.run(
30-
['ps', '-o', 'stat=', '-p', str(pid)],
31-
capture_output=True,
32-
text=True
32+
["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True
3333
)
3434
return ps.stdout.strip()
3535
except subprocess.CalledProcessError:
3636
return ""
3737

38-
def test_zombie_process_cleanup(process_manager):
38+
39+
@pytest.mark.asyncio
40+
async def test_zombie_process_cleanup(process_manager):
3941
"""Test that background processes don't become zombies."""
4042
cmd = ["sh", "-c", "sleep 0.5 & wait"]
41-
process = process_manager.start_process(cmd)
42-
43+
process = await process_manager.start_process(cmd)
44+
4345
# Wait for the background process to finish
44-
time.sleep(1)
45-
46+
await asyncio.sleep(1)
47+
4648
# Get process status
4749
status = get_process_status(process.pid)
48-
50+
4951
# Verify process is either gone or not zombie (Z state)
50-
assert 'Z' not in status, f"Process {process.pid} is zombie (status: {status})"
52+
assert "Z" not in status, f"Process {process.pid} is zombie (status: {status})"
5153

52-
def test_process_timeout(process_manager):
54+
55+
@pytest.mark.asyncio
56+
async def test_process_timeout(process_manager):
5357
"""Test process timeout functionality."""
5458
# Start a process that should timeout
5559
cmd = ["sleep", "10"]
56-
process = process_manager.start_process(cmd, timeout=1)
57-
58-
# Wait slightly longer than the timeout
59-
time.sleep(1.5)
60-
61-
# Verify process was terminated
62-
assert not process.is_running()
63-
assert process.returncode is not None
64-
65-
def test_multiple_process_cleanup(process_manager):
60+
process = await process_manager.start_process(cmd)
61+
62+
try:
63+
# Communicate with timeout
64+
with pytest.raises(TimeoutError):
65+
_, _ = await process_manager.execute_with_timeout(process, timeout=1)
66+
67+
# プロセスが終了するまで待つ
68+
try:
69+
await asyncio.wait_for(process.wait(), timeout=1.0)
70+
except asyncio.TimeoutError:
71+
process.kill() # Force kill
72+
73+
# Wait for termination
74+
await asyncio.wait_for(process.wait(), timeout=0.5)
75+
76+
# Verify process was terminated
77+
assert process.returncode is not None
78+
assert not process.is_running()
79+
finally:
80+
if process.returncode is None:
81+
try:
82+
process.kill()
83+
await asyncio.wait_for(process.wait(), timeout=0.5)
84+
except (ProcessLookupError, asyncio.TimeoutError):
85+
pass
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_multiple_process_cleanup(process_manager):
6690
"""Test cleanup of multiple processes."""
6791
# Start multiple background processes
68-
processes = [
69-
process_manager.start_process(["sleep", "2"])
70-
for _ in range(3)
71-
]
72-
92+
# Start multiple processes in parallel
93+
processes = await asyncio.gather(
94+
*[process_manager.start_process(["sleep", "2"]) for _ in range(3)]
95+
)
96+
7397
# Give them a moment to start
74-
time.sleep(0.1)
75-
76-
# Verify they're all running
77-
assert all(p.is_running() for p in processes)
78-
79-
# Cleanup
80-
process_manager.cleanup_all()
81-
82-
# Give cleanup a moment to complete
83-
time.sleep(0.1)
84-
85-
# Verify all processes are gone
86-
for p in processes:
87-
status = get_process_status(p.pid)
88-
assert status == "", f"Process {p.pid} still exists with status: {status}"
89-
90-
def test_process_group_termination(process_manager):
98+
await asyncio.sleep(0.1)
99+
100+
try:
101+
# Verify they're all running
102+
assert all(p.is_running() for p in processes)
103+
104+
# Cleanup
105+
await process_manager.cleanup_all()
106+
107+
# Give cleanup a moment to complete
108+
await asyncio.sleep(0.1)
109+
110+
# Verify all processes are gone
111+
for p in processes:
112+
status = get_process_status(p.pid)
113+
assert status == "", f"Process {p.pid} still exists with status: {status}"
114+
finally:
115+
# Ensure cleanup in case of test failure
116+
for p in processes:
117+
if p.returncode is None:
118+
try:
119+
p.kill()
120+
except ProcessLookupError:
121+
pass
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_process_group_termination(process_manager):
91126
"""Test that entire process group is terminated."""
92127
# Create a process that spawns children
93128
cmd = ["sh", "-c", "sleep 10 & sleep 10 & sleep 10 & wait"]
94-
process = process_manager.start_process(cmd)
95-
96-
# Give processes time to start
97-
time.sleep(0.5)
98-
99-
# Kill the main process
100-
process.kill()
101-
102-
# Wait a moment for cleanup
103-
time.sleep(0.5)
104-
105-
# Check if any processes from the group remain
106-
ps = subprocess.run(
107-
["pgrep", "-g", str(process.pid)],
108-
capture_output=True
109-
)
110-
assert ps.returncode != 0, "Process group still exists"
129+
process = await process_manager.start_process(cmd)
130+
131+
try:
132+
# Give processes time to start
133+
await asyncio.sleep(0.5)
134+
135+
# Kill the main process
136+
process.kill()
137+
138+
# Wait a moment for cleanup
139+
await asyncio.sleep(0.5)
140+
141+
# Check if any processes from the group remain
142+
ps = subprocess.run(["pgrep", "-g", str(process.pid)], capture_output=True)
143+
assert ps.returncode != 0, "Process group still exists"
144+
finally:
145+
if process.returncode is None:
146+
try:
147+
process.kill()
148+
except ProcessLookupError:
149+
pass

tests/test_server.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ def setup_mock_subprocess(monkeypatch):
4444
"""Set up mock subprocess to avoid interactive shell warnings"""
4545

4646
async def mock_create_subprocess_shell(
47-
cmd, stdin=None, stdout=None, stderr=None, env=None, cwd=None
47+
cmd,
48+
stdin=None,
49+
stdout=None,
50+
stderr=None,
51+
env=None,
52+
cwd=None,
53+
preexec_fn=None,
54+
start_new_session=None,
4855
):
4956
# Return appropriate output based on command
5057
if "echo" in cmd:

0 commit comments

Comments
 (0)