feat(daemon): add PID file management module
Atomic write-then-rename, stale-PID detection via os.kill on POSIX and ctypes.OpenProcess on Windows, context manager for cleanup on exit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user