diff --git a/headroom/install/runtime.py b/headroom/install/runtime.py index d6fd560a5..57fe8d00f 100644 --- a/headroom/install/runtime.py +++ b/headroom/install/runtime.py @@ -173,6 +173,26 @@ def _read_pid(profile: str) -> int | None: return None +def _pid_alive(pid: int) -> bool: + """Return True if ``pid`` names a live process.""" + + if pid <= 0: + return False + try: + import psutil + + return bool(psutil.pid_exists(pid)) + except Exception: + pass + try: + os.kill(pid, 0) + except PermissionError: + return True + except (ProcessLookupError, OSError, SystemError): + return False + return True + + def _clear_pid(profile: str) -> None: path = pid_path(profile) if path.exists(): diff --git a/tests/test_install/test_runtime.py b/tests/test_install/test_runtime.py index c9f144fc4..5705dd48f 100644 --- a/tests/test_install/test_runtime.py +++ b/tests/test_install/test_runtime.py @@ -14,6 +14,7 @@ _clear_pid, _deployment_env, _mount_source, + _pid_alive, _read_pid, _runtime_env, _write_pid, @@ -253,6 +254,34 @@ def test_write_read_and_clear_pid(monkeypatch, tmp_path: Path) -> None: assert _read_pid("default") is None +def test_pid_alive_uses_psutil_without_signal(monkeypatch) -> None: + fake_psutil = types.ModuleType("psutil") + fake_psutil.pid_exists = lambda pid: True if pid > 0 else False # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + monkeypatch.setattr( + "headroom.install.runtime.os.kill", + lambda pid, sig: (_ for _ in ()).throw(AssertionError("os.kill should not run")), + ) + + assert _pid_alive(123) is True + + +def test_pid_alive_falls_back_and_handles_systemerror(monkeypatch) -> None: + fake_psutil = types.ModuleType("psutil") + + def _raise_pid_exists(pid: int) -> bool: + raise RuntimeError("boom") + + fake_psutil.pid_exists = _raise_pid_exists # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + monkeypatch.setattr( + "headroom.install.runtime.os.kill", + lambda pid, sig: (_ for _ in ()).throw(SystemError("WinError 87")), + ) + + assert _pid_alive(123) is False + + def test_runtime_start_lock_is_nonblocking(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) @@ -272,7 +301,12 @@ def test_runtime_start_lock_blocks_another_process(monkeypatch, tmp_path: Path) "with acquire_runtime_start_lock('default') as acquired:\n" " print(acquired)\n" ) - env = {**os.environ, "HOME": str(tmp_path), "PYTHONPATH": str(Path.cwd())} + env = { + **os.environ, + "HOME": str(tmp_path), + "USERPROFILE": str(tmp_path), + "PYTHONPATH": str(Path.cwd()), + } with acquire_runtime_start_lock("default") as acquired: assert acquired is True @@ -519,6 +553,8 @@ def __init__(self, stdout: str = "") -> None: assert runtime_status(python_manifest) == "stopped" _write_pid("default", 125) + monkeypatch.setattr("headroom.install.runtime.pid_alive", lambda pid: True) + assert runtime_status(python_manifest) == "running" monkeypatch.setattr("headroom.install.runtime.pid_alive", lambda pid: False) assert runtime_status(python_manifest) == "stopped"