diff --git a/src/pyra/daemon/pid.py b/src/pyra/daemon/pid.py new file mode 100644 index 0000000..524375f --- /dev/null +++ b/src/pyra/daemon/pid.py @@ -0,0 +1,94 @@ +"""PID file management for the Pyra daemon.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +class PidFileError(OSError): + """Raised when a PID file operation fails due to a live conflicting process.""" + + +class PidFile: + def __init__(self, path: Path) -> None: + self._path = path + + def write(self) -> None: + """Write the current PID atomically. + + Raises PidFileError if a non-stale PID file already exists. + """ + existing = self.read() + if existing is not None and not self.is_stale(): + raise PidFileError( + f"Daemon already running with PID {existing} " + f"(PID file: {self._path})" + ) + tmp = self._path.with_suffix(".pid.tmp") + tmp.write_text(str(os.getpid())) + tmp.replace(self._path) + + def read(self) -> int | None: + """Return the PID from the file, or None if the file is absent or unreadable.""" + try: + return int(self._path.read_text().strip()) + except (FileNotFoundError, ValueError): + return None + + def is_stale(self) -> bool: + """True when the PID file exists but the process no longer runs.""" + pid = self.read() + if pid is None: + return False + return not _process_is_alive(pid) + + def remove(self) -> None: + """Delete the PID file, ignoring FileNotFoundError.""" + try: + self._path.unlink() + except FileNotFoundError: + pass + + def __enter__(self) -> "PidFile": + self.write() + return self + + def __exit__(self, *_: object) -> None: + self.remove() + + +def resolve_pid_path(cfg_path: str) -> Path: + """Expand ~ and return an absolute Path.""" + return Path(cfg_path).expanduser().resolve() + + +# ── Platform-specific process liveness check ───────────────────────────────── + +def _process_is_alive(pid: int) -> bool: + if sys.platform == "win32": + return _win_process_is_alive(pid) + return _posix_process_is_alive(pid) + + +def _posix_process_is_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + # Process exists but is owned by another user — still alive. + return True + + +def _win_process_is_alive(pid: int) -> bool: + import ctypes + + SYNCHRONIZE = 0x00100000 + handle = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, pid) # type: ignore[attr-defined] + if handle == 0: + return False + ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined] + return True diff --git a/tests/unit/test_daemon_pid.py b/tests/unit/test_daemon_pid.py new file mode 100644 index 0000000..7bdb82a --- /dev/null +++ b/tests/unit/test_daemon_pid.py @@ -0,0 +1,103 @@ +"""Unit tests for daemon PID file management.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from pyra.daemon.pid import PidFile, PidFileError, resolve_pid_path + + +def test_write_creates_file(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + p.write() + assert (tmp_path / "daemon.pid").exists() + assert int((tmp_path / "daemon.pid").read_text().strip()) == os.getpid() + + +def test_read_returns_none_when_absent(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + assert p.read() is None + + +def test_read_returns_pid_when_present(tmp_path: Path) -> None: + pid_file = tmp_path / "daemon.pid" + pid_file.write_text("12345") + p = PidFile(pid_file) + assert p.read() == 12345 + + +def test_read_returns_none_on_bad_content(tmp_path: Path) -> None: + pid_file = tmp_path / "daemon.pid" + pid_file.write_text("not-a-number") + p = PidFile(pid_file) + assert p.read() is None + + +def test_is_stale_false_for_self(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + p.write() + assert not p.is_stale() + + +def test_is_stale_true_for_dead_pid(tmp_path: Path) -> None: + pid_file = tmp_path / "daemon.pid" + pid_file.write_text("999999999") # unrealistically large PID + p = PidFile(pid_file) + assert p.is_stale() + + +def test_is_stale_false_when_file_absent(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + assert not p.is_stale() + + +def test_remove_deletes_file(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + p.write() + p.remove() + assert not (tmp_path / "daemon.pid").exists() + + +def test_remove_is_idempotent(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + p.remove() # must not raise + + +def test_context_manager_writes_and_removes(tmp_path: Path) -> None: + pid_file = tmp_path / "daemon.pid" + p = PidFile(pid_file) + with p: + assert pid_file.exists() + assert int(pid_file.read_text().strip()) == os.getpid() + assert not pid_file.exists() + + +def test_write_raises_when_live_pid_exists(tmp_path: Path) -> None: + p = PidFile(tmp_path / "daemon.pid") + p.write() # writes self PID (which is alive) + p2 = PidFile(tmp_path / "daemon.pid") + with pytest.raises(PidFileError): + p2.write() + + +def test_write_succeeds_over_stale_pid(tmp_path: Path) -> None: + pid_file = tmp_path / "daemon.pid" + pid_file.write_text("999999999") # stale + p = PidFile(pid_file) + p.write() # should not raise + assert int(pid_file.read_text().strip()) == os.getpid() + + +def test_resolve_pid_path_expands_tilde() -> None: + result = resolve_pid_path("~/.pyra/daemon.pid") + assert not str(result).startswith("~") + assert result.is_absolute() + + +def test_resolve_pid_path_absolute_unchanged(tmp_path: Path) -> None: + path = tmp_path / "daemon.pid" + result = resolve_pid_path(str(path)) + assert result == path