diff --git a/dirlock/__init__.py b/dirlock/__init__.py index 71cf006..eddb671 100644 --- a/dirlock/__init__.py +++ b/dirlock/__init__.py @@ -1,10 +1,76 @@ import os +import signal import time +import atexit from datetime import datetime +""" +This module provides a directory-based locking mechanism. +It allows for acquiring and releasing locks using directories, +which can be useful in scenarios where file-based based +operations can not be done atomically (e.g. +distributed or cluster filesystem) + +The DirLock class provides methods to acquire and release locks, +and it supports context management for easy usage. + +It also handles cleanup of locks on program exit or signal interrupts. +""" + + +# keep a list of currently acquired locks +# so these can be cleaned up on exit +_allActiveLocks = set() +# we don't want to call the original handler +original_sigint_handler = signal.getsignal(signal.SIGINT) +original_sigterm_handler = signal.getsignal(signal.SIGTERM) + + +# function to clean up all active locks +def _clean_locks(): + global _allActiveLocks + # Create a copy to avoid "Set changed size during iteration" error + locks_to_release = list(_allActiveLocks) + for dl in locks_to_release: + dl.release() + _allActiveLocks.remove(dl) + + +def handle_sigint_cleanup(signum, frame): + """ + Handle SIGINT (Ctrl+C) cleanup. + This function is called when a SIGINT signal is received. + It cleans up all active locks and calls the original signal handler if it exists. + """ + global original_sigint_handler + _clean_locks() + if original_sigint_handler is not None: + original_sigint_handler(signum, frame) + + +def handle_sigterm_cleanup(signum, frame): + """ + Handle SIGTERM cleanup. + This function is called when a SIGTERM signal is received. + It cleans up all active locks and calls the original signal handler if it exists. + """ + global original_sigterm_handler + _clean_locks() + if original_sigterm_handler is not None: + original_sigterm_handler(signum, frame) + + +# normal exit clean up +atexit.register(_clean_locks) + +# ctrl+c cleanup +signal.signal(signal.SIGINT, handle_sigint_cleanup) +# sigterm cleanup +signal.signal(signal.SIGTERM, handle_sigterm_cleanup) class DirLock: default_retry_interval: float = 0.1 + def __init__(self, lock_dir: str, retry_interval: float = None, @@ -30,12 +96,14 @@ def acquire(self): """ Acquire the lock by following the directory-based lock mechanism. """ + global _allActiveLocks start_time = datetime.now() while True: try: os.mkdir(self.lock_dir) self.acquired = True + _allActiveLocks.add(self) break except FileExistsError: pass @@ -51,11 +119,13 @@ def release(self): """ Release the lock if it is held by this instance. """ - try: - os.rmdir(self.lock_dir) - except FileNotFoundError: - pass - self.acquired = False + if self.acquired: + try: + os.rmdir(self.lock_dir) + _allActiveLocks.remove(self) + except FileNotFoundError: + pass + self.acquired = False def __enter__(self): """ @@ -70,6 +140,8 @@ def __exit__(self, exc_type, exc_value, traceback): """ self.release() + class LockTimeoutException(Exception): def __init__(self, message="acquiring lock timed out"): - super().__init__(message) \ No newline at end of file + super().__init__(message) + diff --git a/tests/test_lock.py b/tests/test_lock.py index 053cc1b..f6d4a32 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -1,6 +1,10 @@ -from datetime import datetime import os -from dirlock import DirLock, LockTimeoutException +import signal +import subprocess +import sys +import time +from unittest.mock import patch, MagicMock +from dirlock import DirLock, LockTimeoutException, _allActiveLocks, _clean_locks def test_two_locks(tmpdir): @@ -59,3 +63,272 @@ def test_context_manager(tmpdir): # Ensure that the lock directory is removed after exiting the context assert not os.path.exists(str(lockdir)) + + +def test_clean_locks_function(tmpdir): + """Test that _clean_locks properly releases all active locks.""" + lockdir1 = os.path.join(tmpdir, '.lock1') + lockdir2 = os.path.join(tmpdir, '.lock2') + + lock1 = DirLock(lockdir1) + lock2 = DirLock(lockdir2) + + # Acquire both locks + lock1.acquire() + lock2.acquire() + + assert lock1.acquired + assert lock2.acquired + assert os.path.exists(lockdir1) + assert os.path.exists(lockdir2) + assert len(_allActiveLocks) == 2 + + # Call _clean_locks to release all + _clean_locks() + + # Verify all locks are released + assert not lock1.acquired + assert not lock2.acquired + assert not os.path.exists(lockdir1) + assert not os.path.exists(lockdir2) + assert len(_allActiveLocks) == 0 + + +def test_signal_handlers_mock(tmpdir): + """Test signal handlers using mocks.""" + from dirlock import handle_sigint_cleanup, handle_sigterm_cleanup + + lockdir = os.path.join(tmpdir, '.lock') + lock = DirLock(lockdir) + lock.acquire() + + assert lock.acquired + assert os.path.exists(lockdir) + assert len(_allActiveLocks) == 1 + + # Mock the original signal handler + mock_handler = MagicMock() + + # Test SIGINT handler + with patch('dirlock.original_sigint_handler', mock_handler): + handle_sigint_cleanup(signal.SIGINT, None) + mock_handler.assert_called_once_with(signal.SIGINT, None) + + # Verify lock was cleaned up + assert not lock.acquired + assert not os.path.exists(lockdir) + assert len(_allActiveLocks) == 0 + + # Test SIGTERM handler + lock.acquire() # Re-acquire for second test + assert lock.acquired + assert len(_allActiveLocks) == 1 + + mock_handler.reset_mock() + with patch('dirlock.original_sigterm_handler', mock_handler): + handle_sigterm_cleanup(signal.SIGTERM, None) + mock_handler.assert_called_once_with(signal.SIGTERM, None) + + # Verify lock was cleaned up + assert not lock.acquired + assert len(_allActiveLocks) == 0 + + +def test_signal_handlers_with_none_original(tmpdir): + """Test signal handlers when original handlers are None.""" + from dirlock import handle_sigint_cleanup, handle_sigterm_cleanup + + lockdir = os.path.join(tmpdir, '.lock') + lock = DirLock(lockdir) + lock.acquire() + + assert lock.acquired + assert len(_allActiveLocks) == 1 + + # Test with None original handlers (should not raise exception) + with patch('dirlock.original_sigint_handler', None): + handle_sigint_cleanup(signal.SIGINT, None) + + assert not lock.acquired + assert len(_allActiveLocks) == 0 + + # Test SIGTERM with None + lock.acquire() + assert len(_allActiveLocks) == 1 + + with patch('dirlock.original_sigterm_handler', None): + handle_sigterm_cleanup(signal.SIGTERM, None) + + assert not lock.acquired + assert len(_allActiveLocks) == 0 + + +def test_atexit_integration(tmpdir): + """Test atexit integration using subprocess.""" + # Create a test script that uses DirLock and exits normally + test_script = f''' +import os +import sys +sys.path.insert(0, "{os.path.dirname(os.path.dirname(__file__))}") +from dirlock import DirLock + +lockdir = "{os.path.join(tmpdir, '.lock')}" +lock = DirLock(lockdir) +lock.acquire() + +# Write a marker file to show the lock was acquired +with open("{os.path.join(tmpdir, 'acquired')}", "w") as f: + f.write("locked") + +# Exit normally - atexit should clean up the lock +''' + + script_path = os.path.join(tmpdir, 'test_script.py') + with open(script_path, 'w') as f: + f.write(test_script) + + # Run the script + result = subprocess.run([sys.executable, script_path], + capture_output=True, text=True) + + # Check that script ran successfully + assert result.returncode == 0, f"Script failed: {result.stderr}" + + # Check that the lock was acquired + assert os.path.exists(os.path.join(tmpdir, 'acquired')) + + # Check that the lock was cleaned up on exit + assert not os.path.exists(os.path.join(tmpdir, '.lock')) + + +def test_signal_integration_sigterm(tmpdir): + """Test SIGTERM signal handling using subprocess.""" + # Create a test script that uses DirLock and receives SIGTERM + test_script = f''' +import os +import sys +import signal +import time +sys.path.insert(0, "{os.path.dirname(os.path.dirname(__file__))}") +from dirlock import DirLock + +lockdir = "{os.path.join(tmpdir, '.lock')}" +lock = DirLock(lockdir) +lock.acquire() + +# Write a marker file to show the lock was acquired +with open("{os.path.join(tmpdir, 'acquired')}", "w") as f: + f.write("locked") + +# Wait for signal +try: + time.sleep(10) # Will be interrupted by SIGTERM +except KeyboardInterrupt: + pass +''' + + script_path = os.path.join(tmpdir, 'test_script.py') + with open(script_path, 'w') as f: + f.write(test_script) + + # Run the script in background + process = subprocess.Popen([sys.executable, script_path]) + + # Give it time to acquire the lock + time.sleep(0.5) + + # Check that the lock was acquired + assert os.path.exists(os.path.join(tmpdir, 'acquired')) + assert os.path.exists(os.path.join(tmpdir, '.lock')) + + # Send SIGTERM + process.terminate() + process.wait(timeout=5) + + # Check that the lock was cleaned up after SIGTERM + assert not os.path.exists(os.path.join(tmpdir, '.lock')) + + +def test_multiple_locks_cleanup(tmpdir): + """Test that multiple locks are properly cleaned up.""" + locks = [] + lockdirs = [] + + # Create multiple locks + for i in range(5): + lockdir = os.path.join(tmpdir, f'.lock{i}') + lock = DirLock(lockdir) + lock.acquire() + locks.append(lock) + lockdirs.append(lockdir) + + # Verify all locks are acquired + assert len(_allActiveLocks) == 5 + for lock in locks: + assert lock.acquired + for lockdir in lockdirs: + assert os.path.exists(lockdir) + + # Clean all locks + _clean_locks() + + # Verify all locks are released + assert len(_allActiveLocks) == 0 + for lock in locks: + assert not lock.acquired + for lockdir in lockdirs: + assert not os.path.exists(lockdir) + + +def test_atexit_on_unhandled_exception(tmpdir): + """Test that atexit cleanup works even when script crashes with unhandled exception.""" + # Create a test script that uses DirLock and crashes with an exception + test_script = f''' +import os +import sys +sys.path.insert(0, "{os.path.dirname(os.path.dirname(__file__))}") +from dirlock import DirLock + +lockdir = "{os.path.join(tmpdir, '.lock')}" +lock = DirLock(lockdir) +lock.acquire() + +# Write a marker file to show the lock was acquired +with open("{os.path.join(tmpdir, 'acquired')}", "w") as f: + f.write("locked") + +# Verify lock directory exists before crashing +if not os.path.exists(lockdir): + raise RuntimeError("Lock directory was not created!") + +# Write another marker to confirm lock was in place +with open("{os.path.join(tmpdir, 'lock_verified')}", "w") as f: + f.write("lock was verified to exist") + +# Crash with an unhandled exception +raise ValueError("Intentional crash to test cleanup") +''' + + script_path = os.path.join(tmpdir, 'test_crash_script.py') + with open(script_path, 'w') as f: + f.write(test_script) + + # Run the script (it should crash) + result = subprocess.run([sys.executable, script_path], + capture_output=True, text=True) + + # Check that script crashed as expected + assert result.returncode != 0, "Script should have crashed" + assert "ValueError" in result.stderr, "Should contain the exception" + assert "Intentional crash" in result.stderr, "Should contain our error message" + + # Check that the lock was acquired + assert os.path.exists(os.path.join(tmpdir, 'acquired')) + + # Check that the lock was verified to exist before the crash + assert os.path.exists(os.path.join(tmpdir, 'lock_verified')), \ + "Lock should have been verified before crash" + + # Most importantly: check that the lock was cleaned up despite the crash + assert not os.path.exists(os.path.join(tmpdir, '.lock')), \ + "Lock should be cleaned up even after unhandled exception"