diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py index ac5f26a9..049caa19 100644 --- a/src/filelock/_unix.py +++ b/src/filelock/_unix.py @@ -38,9 +38,9 @@ class UnixFileLock(BaseFileLock): """ Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems. - Lock file cleanup: Unix and macOS delete the lock file reliably after release, even in - multi-threaded scenarios. Unlike Windows, Unix allows unlinking files that other processes - have open. + The lock file is intentionally left in place after release. Unlinking a locked file on Unix + can split waiters across different inodes and break mutual exclusion for processes that + coordinate via the same path. """ def _acquire(self) -> None: # noqa: C901, PLR0912 @@ -103,10 +103,8 @@ def _fallback_to_soft_lock(self) -> None: def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) self._context.lock_file_fd = None - with suppress(OSError): - Path(self.lock_file).unlink() fcntl.flock(fd, fcntl.LOCK_UN) - with suppress(OSError): # close can raise EIO on FUSE/Docker bind-mount filesystems after unlink + with suppress(OSError): # close can raise EIO on FUSE/Docker bind-mount filesystems os.close(fd) diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 5125fd19..599f0c89 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -945,7 +945,7 @@ def test_mtime_zero_exit_branch( lock.acquire(timeout=0) -@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) +@pytest.mark.parametrize("lock_type", [SoftFileLock]) def test_lock_file_removed_after_release(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock = lock_type(str(lock_path)) @@ -955,7 +955,7 @@ def test_lock_file_removed_after_release(tmp_path: Path, lock_type: type[BaseFil @pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics") -def test_concurrent_acquire_release_removes_lock_file(tmp_path: Path) -> None: +def test_concurrent_acquire_release_keeps_lock_file(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" errors: list[Exception] = [] @@ -974,11 +974,11 @@ def worker() -> None: for thread in threads: thread.join(timeout=30) assert not errors, errors - assert not lock_path.exists() + assert lock_path.exists() @pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics") -def test_lock_acquired_after_release_unlinks(tmp_path: Path) -> None: +def test_lock_acquired_after_release_keeps_path(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" first = FileLock(str(lock_path), is_singleton=False) second = FileLock(str(lock_path), is_singleton=False) @@ -986,12 +986,39 @@ def test_lock_acquired_after_release_unlinks(tmp_path: Path) -> None: first.acquire() assert lock_path.exists() first.release() - assert not lock_path.exists() + assert lock_path.exists() second.acquire() assert lock_path.exists() second.release() - assert not lock_path.exists() + assert lock_path.exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics") +def test_waiter_fd_cannot_split_lock_after_release(tmp_path: Path) -> None: + import fcntl + from errno import EAGAIN, EWOULDBLOCK + + lock_path = tmp_path / "test.lock" + first = FileLock(str(lock_path), is_singleton=False) + replacement = FileLock(str(lock_path), is_singleton=False) + + first.acquire() + waiter_fd = os.open(lock_path, os.O_RDWR) + + try: + first.release() + assert lock_path.exists() + + replacement.acquire() + try: + with pytest.raises(BlockingIOError) as exc_info: + fcntl.flock(waiter_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert exc_info.value.errno in {EAGAIN, EWOULDBLOCK} + finally: + replacement.release() + finally: + os.close(waiter_fd) @pytest.mark.skipif(sys.platform == "win32", reason="Unix flock semantics")