diff --git a/Makefile b/Makefile index 1b8ffa1..8c71e5b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ PYTHON ?= uv run python UV_CACHE_DIR ?= .uv-cache +PYTEST_FLAGS ?= -n auto OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -27,7 +28,7 @@ help: ' lint Run Ruff lint checks' \ ' format Run Ruff formatter' \ ' typecheck Run mypy' \ - ' test Run pytest' \ + ' test Run pytest in parallel when multiple cores are available' \ ' check Run lint, typecheck, and tests' \ ' dist-check Smoke-test the installed pyro CLI and environment UX' \ ' pypi-publish Build, validate, and upload the package to PyPI' \ @@ -76,7 +77,7 @@ typecheck: uv run mypy test: - uv run pytest + uv run pytest $(PYTEST_FLAGS) check: lint typecheck test diff --git a/pyproject.toml b/pyproject.toml index 09f08ca..044f601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev = [ "pre-commit>=4.5.1", "pytest>=9.0.2", "pytest-cov>=7.0.0", + "pytest-xdist>=3.8.0", "ruff>=0.15.4", ] diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index eb90462..e25be5f 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -1589,6 +1589,23 @@ def _stop_process_group(pid: int, *, wait_seconds: int = 5) -> tuple[bool, bool] return True, True +def _linux_process_state(pid: int) -> str | None: + stat_path = Path("/proc") / str(pid) / "stat" + try: + raw_stat = stat_path.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + if raw_stat == "": + return None + closing_paren = raw_stat.rfind(")") + if closing_paren == -1: + return None + suffix = raw_stat[closing_paren + 2 :] + if suffix == "": + return None + return suffix.split(" ", 1)[0] + + def _run_service_probe_command( cwd: Path, command: str, @@ -1962,6 +1979,11 @@ def _ensure_no_symlink_parents(root: Path, target_path: Path, member_name: str) def _pid_is_running(pid: int | None) -> bool: if pid is None: return False + process_state = _linux_process_state(pid) + if process_state == "Z": + return False + if process_state is not None: + return True try: os.kill(pid, 0) except ProcessLookupError: diff --git a/tests/test_runtime_network_check.py b/tests/test_runtime_network_check.py index 35b5a87..0e26b5d 100644 --- a/tests/test_runtime_network_check.py +++ b/tests/test_runtime_network_check.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + import pytest import pyro_mcp.runtime_network_check as runtime_network_check @@ -43,6 +45,7 @@ def test_network_check_uses_network_enabled_manager(monkeypatch: pytest.MonkeyPa def test_network_check_main_fails_on_unsuccessful_command( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: + monkeypatch.setattr(sys, "argv", ["runtime-network-check"]) monkeypatch.setattr( runtime_network_check, "run_network_check", diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index f8010d3..22e953c 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -2937,6 +2937,11 @@ def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch assert kill_calls == [signal.SIGTERM, signal.SIGKILL] +def test_pid_is_running_treats_zombies_as_stopped(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(vm_manager_module, "_linux_process_state", lambda _pid: "Z") + assert vm_manager_module._pid_is_running(123) is False # noqa: SLF001 + + def test_workspace_service_probe_and_refresh_helpers( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_workspace_shells.py b/tests/test_workspace_shells.py index 4f4f206..b0efcd9 100644 --- a/tests/test_workspace_shells.py +++ b/tests/test_workspace_shells.py @@ -154,6 +154,7 @@ def test_workspace_shells_write_and_signal_runtime_errors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + real_killpg = os.killpg session = create_local_shell( workspace_id="workspace-6", shell_id="shell-6", @@ -189,6 +190,7 @@ def test_workspace_shells_write_and_signal_runtime_errors( monkeypatch.setattr("pyro_mcp.workspace_shells.os.killpg", _raise_killpg) with pytest.raises(RuntimeError, match="not running"): session.send_signal("INT") + monkeypatch.setattr("pyro_mcp.workspace_shells.os.killpg", real_killpg) finally: try: session.close() diff --git a/uv.lock b/uv.lock index 9e194db..d688e08 100644 --- a/uv.lock +++ b/uv.lock @@ -275,6 +275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "filelock" version = "3.25.0" @@ -718,6 +727,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -730,6 +740,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.15.4" }, ] @@ -763,6 +774,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.1.0"