Skip to content

Commit b993032

Browse files
committed
Merge branch 'feature/zombie-process-fix' into refactor/shell-executor
2 parents 4e27059 + 1678c51 commit b993032

File tree

7 files changed

+351
-67
lines changed

7 files changed

+351
-67
lines changed

.github/workflows/publish.yml

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
name: Publish
2+
23
on:
34
push:
45
tags:
56
- "v*"
6-
strategy:
7-
matrix:
8-
python-version: ["3.11"]
97

108
jobs:
119
test:
1210
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.11"]
14+
1315
steps:
1416
- uses: actions/checkout@v4
1517

@@ -23,14 +25,13 @@ jobs:
2325
python -m pip install --upgrade pip
2426
pip install uv
2527
26-
- name: Install dev/test dependencies
28+
- name: Install dependencies
2729
run: |
28-
pip install -e ".[dev]"
29-
pip install -e ".[test]"
30+
uv pip install -e ".[dev,test]"
3031
31-
- name: Run tests
32+
- name: Run tests and checks
3233
run: |
33-
make check
34+
make all
3435
3536
publish:
3637
needs: test
@@ -44,14 +45,13 @@ jobs:
4445

4546
- name: Update version from tag
4647
run: |
47-
# Strip 'v' prefix from tag and update version.py
4848
VERSION=${GITHUB_REF#refs/tags/v}
4949
echo "__version__ = \"${VERSION}\"" > src/mcp_shell_server/version.py
5050
51-
- name: Set up Python ${{ matrix.python-version }}
51+
- name: Set up Python 3.11
5252
uses: actions/setup-python@v5
5353
with:
54-
python-version: ${{ matrix.python-version }}
54+
python-version: "3.11"
5555

5656
- name: Install uv
5757
run: |
@@ -60,10 +60,11 @@ jobs:
6060
6161
- name: Build package
6262
run: |
63-
uv build
63+
uv pip install build
64+
python -m build
6465
6566
- name: Publish to PyPI
66-
env:
67-
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
68-
run: |
69-
uv publish --token $PYPI_TOKEN
67+
uses: pypa/gh-action-pypi-publish@release/v1
68+
with:
69+
password: ${{ secrets.PYPI_TOKEN }}
70+
password: ${{ secrets.PYPI_TOKEN }}

.github/workflows/test.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,17 @@ jobs:
2626
python -m pip install --upgrade pip
2727
pip install uv
2828
29-
- name: Install dev/test dependencies
29+
- name: Install dependencies
3030
run: |
31-
pip install -e ".[dev]"
32-
pip install -e ".[test]"
31+
uv pip install -e ".[dev,test]"
3332
34-
- name: Run lint and typecheck
33+
- name: Run format checks and typecheck
3534
run: |
36-
make lint typecheck
35+
make check
3736
3837
- name: Run tests with coverage
3938
run: |
40-
pytest --cov --cov-report=xml
39+
make coverage
4140
4241
- name: Upload coverage to Codecov
4342
uses: codecov/codecov-action@v5

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ asyncio_mode = "strict"
4141
testpaths = "tests"
4242
# Set default event loop scope for async tests
4343
asyncio_default_fixture_loop_scope = "function"
44+
markers = [
45+
"macos: marks tests that should only run on macOS",
46+
"slow: marks tests as slow running",
47+
]
4448
filterwarnings = [
4549
"ignore::RuntimeWarning:selectors:",
4650
"ignore::pytest.PytestUnhandledCoroutineWarning:",

src/mcp_shell_server/process_manager.py

Lines changed: 157 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,131 @@
33
import asyncio
44
import logging
55
import os
6-
from typing import IO, Any, Dict, List, Optional, Tuple, Union
6+
import signal
7+
from typing import IO, Any, Dict, List, Optional, Set, Tuple, Union
8+
from weakref import WeakSet
79

810

911
class ProcessManager:
1012
"""Manages process creation, execution, and cleanup for shell commands."""
1113

14+
def __init__(self):
15+
"""Initialize ProcessManager with signal handling setup."""
16+
self._processes: Set[asyncio.subprocess.Process] = WeakSet()
17+
self._original_sigint_handler = None
18+
self._original_sigterm_handler = None
19+
self._setup_signal_handlers()
20+
21+
def _setup_signal_handlers(self) -> None:
22+
"""Set up signal handlers for graceful process management."""
23+
if os.name != "posix":
24+
return
25+
26+
def handle_termination(signum: int, _: Any) -> None:
27+
"""Handle termination signals by cleaning up processes."""
28+
if self._processes:
29+
for process in self._processes:
30+
try:
31+
if process.returncode is None:
32+
process.terminate()
33+
except Exception as e:
34+
logging.warning(
35+
f"Error terminating process on signal {signum}: {e}"
36+
)
37+
38+
# Restore original handler and re-raise signal
39+
if signum == signal.SIGINT and self._original_sigint_handler:
40+
signal.signal(signal.SIGINT, self._original_sigint_handler)
41+
elif signum == signal.SIGTERM and self._original_sigterm_handler:
42+
signal.signal(signal.SIGTERM, self._original_sigterm_handler)
43+
44+
# Re-raise signal
45+
os.kill(os.getpid(), signum)
46+
47+
# Store original handlers
48+
self._original_sigint_handler = signal.signal(signal.SIGINT, handle_termination)
49+
self._original_sigterm_handler = signal.signal(
50+
signal.SIGTERM, handle_termination
51+
)
52+
53+
async def start_process_async(
54+
self, cmd: List[str], timeout: Optional[int] = None
55+
) -> asyncio.subprocess.Process:
56+
"""Start a new process asynchronously.
57+
58+
Args:
59+
cmd: Command to execute as list of strings
60+
timeout: Optional timeout in seconds
61+
62+
Returns:
63+
Process object
64+
"""
65+
process = await self.create_process(
66+
" ".join(cmd), directory=None, timeout=timeout
67+
)
68+
process.is_running = lambda self=process: self.returncode is None # type: ignore
69+
return process
70+
71+
async def start_process(
72+
self, cmd: List[str], timeout: Optional[int] = None
73+
) -> asyncio.subprocess.Process:
74+
"""Start a new process asynchronously.
75+
76+
Args:
77+
cmd: Command to execute as list of strings
78+
timeout: Optional timeout in seconds
79+
80+
Returns:
81+
Process object
82+
"""
83+
process = await self.create_process(
84+
" ".join(cmd), directory=None, timeout=timeout
85+
)
86+
process.is_running = lambda self=process: self.returncode is None # type: ignore
87+
return process
88+
89+
async def cleanup_processes(
90+
self, processes: List[asyncio.subprocess.Process]
91+
) -> None:
92+
"""Clean up processes by killing them if they're still running.
93+
94+
Args:
95+
processes: List of processes to clean up
96+
"""
97+
cleanup_tasks = []
98+
for process in processes:
99+
if process.returncode is None:
100+
try:
101+
# Force kill immediately as required by tests
102+
process.kill()
103+
cleanup_tasks.append(asyncio.create_task(process.wait()))
104+
except Exception as e:
105+
logging.warning(f"Error killing process: {e}")
106+
107+
if cleanup_tasks:
108+
try:
109+
# Wait for all processes to be killed
110+
await asyncio.wait(cleanup_tasks, timeout=5)
111+
except asyncio.TimeoutError:
112+
logging.error("Process cleanup timed out")
113+
except Exception as e:
114+
logging.error(f"Error during process cleanup: {e}")
115+
116+
async def cleanup_all(self) -> None:
117+
"""Clean up all tracked processes."""
118+
if self._processes:
119+
processes = list(self._processes)
120+
await self.cleanup_processes(processes)
121+
self._processes.clear()
122+
12123
async def create_process(
13124
self,
14125
shell_cmd: str,
15126
directory: Optional[str],
16127
stdin: Optional[str] = None,
17128
stdout_handle: Any = asyncio.subprocess.PIPE,
18129
envs: Optional[Dict[str, str]] = None,
130+
timeout: Optional[int] = None,
19131
) -> asyncio.subprocess.Process:
20132
"""Create a new subprocess with the given parameters.
21133
@@ -25,23 +137,34 @@ async def create_process(
25137
stdin (Optional[str]): Input to be passed to the process
26138
stdout_handle: File handle or PIPE for stdout
27139
envs (Optional[Dict[str, str]]): Additional environment variables
140+
timeout (Optional[int]): Timeout in seconds
28141
29142
Returns:
30143
asyncio.subprocess.Process: Created process
144+
145+
Raises:
146+
ValueError: If process creation fails
31147
"""
32148
try:
33-
return await asyncio.create_subprocess_shell(
149+
process = await asyncio.create_subprocess_shell(
34150
shell_cmd,
35151
stdin=asyncio.subprocess.PIPE,
36152
stdout=stdout_handle,
37153
stderr=asyncio.subprocess.PIPE,
38154
env={**os.environ, **(envs or {})},
39155
cwd=directory,
40156
)
157+
158+
# Add process to tracked set
159+
self._processes.add(process)
160+
return process
161+
41162
except OSError as e:
42163
raise ValueError(f"Failed to create process: {str(e)}") from e
43164
except Exception as e:
44-
raise ValueError(f"Unexpected error: {str(e)}") from e
165+
raise ValueError(
166+
f"Unexpected error during process creation: {str(e)}"
167+
) from e
45168

46169
async def execute_with_timeout(
47170
self,
@@ -64,22 +187,38 @@ async def execute_with_timeout(
64187
"""
65188
stdin_bytes = stdin.encode() if stdin else None
66189

67-
async def communicate_with_timeout():
190+
async def _kill_process():
191+
if process.returncode is not None:
192+
return
193+
68194
try:
69-
return await process.communicate(input=stdin_bytes)
70-
except asyncio.TimeoutError:
71-
process.kill() # Kill process on timeout
72-
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)
73206
except Exception as e:
74-
try:
75-
await process.wait()
76-
except Exception:
77-
pass
78-
raise e
207+
logging.warning(f"Error killing process: {e}")
79208

80-
if timeout:
81-
return await asyncio.wait_for(communicate_with_timeout(), timeout=timeout)
82-
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
83222

84223
async def execute_pipeline(
85224
self,
@@ -126,6 +265,8 @@ async def execute_pipeline(
126265
),
127266
envs=envs,
128267
)
268+
if not hasattr(process, "is_running"):
269+
process.is_running = lambda self=process: self.returncode is None # type: ignore
129270
processes.append(process)
130271

131272
try:
@@ -171,29 +312,3 @@ async def execute_pipeline(
171312

172313
finally:
173314
await self.cleanup_processes(processes)
174-
175-
async def cleanup_processes(
176-
self, processes: List[asyncio.subprocess.Process]
177-
) -> None:
178-
"""Clean up processes by killing them if they're still running.
179-
180-
Args:
181-
processes: List of processes to clean up
182-
"""
183-
cleanup_tasks = []
184-
for process in processes:
185-
if process.returncode is None:
186-
try:
187-
process.kill()
188-
cleanup_tasks.append(asyncio.create_task(process.wait()))
189-
except Exception as e:
190-
logging.warning(f"Error cleaning up process: {e}")
191-
192-
if cleanup_tasks:
193-
try:
194-
# Set a timeout for cleanup to prevent hanging
195-
await asyncio.wait_for(asyncio.gather(*cleanup_tasks), timeout=5)
196-
except asyncio.TimeoutError:
197-
logging.warning("Process cleanup timed out")
198-
except Exception as e:
199-
logging.warning(f"Error during process cleanup: {e}")

0 commit comments

Comments
 (0)