Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions src/filelock/_unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
39 changes: 33 additions & 6 deletions tests/test_filelock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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] = []

Expand All @@ -974,24 +974,51 @@ 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)

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")
Expand Down