mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
"""Tests for the file-based locking module (projects/lock.py).
|
|
|
|
Validates that:
|
|
- Lock acquisition succeeds when no lock exists.
|
|
- Lock acquisition fails (LockError) when already held by live process.
|
|
- Stale locks (from dead processes) are detected and cleaned up.
|
|
- Lock is released on explicit release_lock call.
|
|
- Lock is released via atexit on process exit (tested implicitly).
|
|
- is_locked correctly reports lock status.
|
|
- get_lock_holder returns holder information.
|
|
- Lock file contains PID information.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
|
|
if str(_scripts_dir) not in sys.path:
|
|
sys.path.insert(0, str(_scripts_dir))
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from binary_analysis.domain.errors import BinaryAnalysisError
|
|
from binary_analysis.projects.lock import (
|
|
LockError,
|
|
acquire_lock,
|
|
get_lock_holder,
|
|
is_locked,
|
|
release_lock,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def project_path(tmp_path: Path) -> str:
|
|
"""Fixture: a temp directory acting as a project workspace."""
|
|
p = str(tmp_path)
|
|
return p
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock acquisition
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAcquireLock:
|
|
"""Tests for acquire_lock."""
|
|
|
|
def test_acquire_when_no_lock_exists(self, project_path: str) -> None:
|
|
"""Acquiring a lock when none exists succeeds."""
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert holder is not None
|
|
assert "pid=" in holder
|
|
assert is_locked(project_path)
|
|
release_lock(project_path)
|
|
|
|
def test_lock_file_created(self, project_path: str) -> None:
|
|
"""After acquiring, a lock file exists in the project directory."""
|
|
acquire_lock(project_path, "test-project")
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
assert os.path.exists(lock_file)
|
|
release_lock(project_path)
|
|
|
|
def test_lock_file_contains_pid(self, project_path: str) -> None:
|
|
"""The lock file contains the current process PID."""
|
|
acquire_lock(project_path, "test-project")
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
content = Path(lock_file).read_text()
|
|
assert f"pid={os.getpid()}" in content
|
|
release_lock(project_path)
|
|
|
|
def test_duplicate_acquire_local_fails(self, project_path: str) -> None:
|
|
"""Same process trying to acquire again succeeds (re-entrant)."""
|
|
# Note: we don't prevent re-entrant locks in the same process
|
|
# because atexit only registers once. But os.O_EXCL prevents
|
|
# double acquisition — we should test for it explicitly.
|
|
acquire_lock(project_path, "test-project")
|
|
# Same process trying again should fail because lock exists
|
|
with pytest.raises(LockError, match="locked by another process"):
|
|
acquire_lock(project_path, "test-project")
|
|
release_lock(project_path)
|
|
|
|
def test_lock_holder_info(self, project_path: str) -> None:
|
|
"""Holder info includes host, purpose, and timestamp."""
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert "host=" in holder
|
|
assert "purpose=" in holder
|
|
assert "acquired_at=" in holder
|
|
release_lock(project_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock release
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReleaseLock:
|
|
"""Tests for release_lock."""
|
|
|
|
def test_release_unlocks(self, project_path: str) -> None:
|
|
"""Releasing a lock removes the lock file and clears locked state."""
|
|
acquire_lock(project_path, "test-project")
|
|
assert is_locked(project_path)
|
|
result = release_lock(project_path)
|
|
assert result is True
|
|
assert not is_locked(project_path)
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
assert not os.path.exists(lock_file)
|
|
|
|
def test_release_no_lock(self, project_path: str) -> None:
|
|
"""Releasing when no lock exists returns False."""
|
|
result = release_lock(project_path)
|
|
assert result is False
|
|
|
|
def test_acquire_after_release(self, project_path: str) -> None:
|
|
"""After releasing, a new lock can be acquired."""
|
|
acquire_lock(project_path, "test-project")
|
|
release_lock(project_path)
|
|
# Should succeed — lock was released
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert holder is not None
|
|
release_lock(project_path)
|
|
|
|
def test_cannot_release_other_process_lock(self, project_path: str) -> None:
|
|
"""A process cannot release a lock it doesn't own."""
|
|
# Write a fake lock file with a different PID
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
Path(lock_file).write_text("pid=99999 host=other purpose=test acquired_at=now")
|
|
result = release_lock(project_path)
|
|
assert result is False
|
|
assert os.path.exists(lock_file)
|
|
# Clean up manually
|
|
os.unlink(lock_file)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock status checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsLocked:
|
|
"""Tests for is_locked."""
|
|
|
|
def test_not_locked_initially(self, project_path: str) -> None:
|
|
"""A fresh project workspace is not locked."""
|
|
assert not is_locked(project_path)
|
|
|
|
def test_locked_after_acquire(self, project_path: str) -> None:
|
|
"""After acquiring, is_locked returns True."""
|
|
acquire_lock(project_path, "test-project")
|
|
assert is_locked(project_path)
|
|
release_lock(project_path)
|
|
|
|
def test_not_locked_after_release(self, project_path: str) -> None:
|
|
"""After releasing, is_locked returns False."""
|
|
acquire_lock(project_path, "test-project")
|
|
release_lock(project_path)
|
|
assert not is_locked(project_path)
|
|
|
|
|
|
class TestGetLockHolder:
|
|
"""Tests for get_lock_holder."""
|
|
|
|
def test_none_when_no_lock(self, project_path: str) -> None:
|
|
"""No lock holder when no lock exists."""
|
|
assert get_lock_holder(project_path) is None
|
|
|
|
def test_returns_holder_info(self, project_path: str) -> None:
|
|
"""get_lock_holder returns the holder string."""
|
|
acquire_lock(project_path, "test-project")
|
|
holder = get_lock_holder(project_path)
|
|
assert holder is not None
|
|
assert f"pid={os.getpid()}" in holder
|
|
release_lock(project_path)
|
|
|
|
def test_none_after_release(self, project_path: str) -> None:
|
|
"""After release, holder is None."""
|
|
acquire_lock(project_path, "test-project")
|
|
release_lock(project_path)
|
|
assert get_lock_holder(project_path) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stale lock detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStaleLock:
|
|
"""Tests for stale lock detection."""
|
|
|
|
def test_stale_lock_with_nonexistent_pid(self, project_path: str) -> None:
|
|
"""A lock with a PID that doesn't exist is detected as stale."""
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
# PID 99999 is extremely unlikely to exist
|
|
Path(lock_file).write_text("pid=99999 host=fake purpose=test acquired_at=now")
|
|
# Should be detected as stale and cleaned up on acquire
|
|
assert not is_locked(project_path) # Stale = not locked
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert holder is not None
|
|
release_lock(project_path)
|
|
|
|
def test_stale_lock_with_invalid_content(self, project_path: str) -> None:
|
|
"""A lock file with unparseable content is treated as stale."""
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
Path(lock_file).write_text("garbage content, no PID at all")
|
|
# Should be detected as stale
|
|
assert not is_locked(project_path)
|
|
# Acquire should clean it up
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert holder is not None
|
|
release_lock(project_path)
|
|
|
|
def test_stale_lock_no_pid_field(self, project_path: str) -> None:
|
|
"""A lock file without a pid= field is stale."""
|
|
lock_file = os.path.join(project_path, "project.lock")
|
|
Path(lock_file).write_text("host=some purpose=analysis")
|
|
assert not is_locked(project_path)
|
|
holder = acquire_lock(project_path, "test-project")
|
|
assert holder is not None
|
|
release_lock(project_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lock error behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLockError:
|
|
"""Tests for LockError."""
|
|
|
|
def test_lock_error_is_binary_analysis_error(self) -> None:
|
|
"""LockError is a BinaryAnalysisError."""
|
|
err = LockError("test-project", "Held by: pid=12345")
|
|
assert isinstance(err, BinaryAnalysisError)
|
|
|
|
def test_lock_error_message(self) -> None:
|
|
"""LockError contains descriptive message."""
|
|
err = LockError("test-project", "Held by: pid=12345")
|
|
assert "locked by another process" in err.message
|
|
assert "test-project" in err.message
|
|
|
|
def test_lock_error_exit_code(self) -> None:
|
|
"""LockError has GENERIC_ERROR exit code."""
|
|
err = LockError("test-project")
|
|
assert err.exit_code == 1
|