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,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