From 7f2318a154050247c787ac431f378f442a163325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 7 Jul 2026 10:00:49 -0700 Subject: [PATCH] soft_rw: evict a non-regular write marker without reading it _read_marker read the marker to judge it, but reading a FIFO diverges by platform: an empty non-blocking read yields 0 bytes on Linux/macOS (so the FIFO parses as a malformed marker and gets evicted) but raises EAGAIN on FreeBSD, where _read_marker then returned None, _break_stale_marker bailed, and the stale FIFO wedged the acquire until timeout (#587). A legitimate marker is always a regular file, so fstat the fd and report anything else as a malformed marker straight away -- its mtime still drives stale eviction -- instead of reading it. fstat classifies the FIFO the same on every platform, so the writerless case FreeBSD tripped on and the writer-attached case that raises EAGAIN everywhere both evict. Add test_fifo_write_marker_with_writer_is_evicted: attaching a writer forces the EAGAIN path on any platform, reproducing the FreeBSD failure portably. It times out at the same _wait_for line before the fix. --- src/filelock/_soft_rw/_sync.py | 15 ++++++++++----- tests/soft_rw/test_soft_rw_sync.py | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/filelock/_soft_rw/_sync.py b/src/filelock/_soft_rw/_sync.py index 653fad2d..1d8415af 100644 --- a/src/filelock/_soft_rw/_sync.py +++ b/src/filelock/_soft_rw/_sync.py @@ -781,11 +781,16 @@ def _read_marker(name: str, *, dir_fd: int | None = None) -> tuple[_MarkerInfo | if fd is None: return None try: - try: - st = os.fstat(fd) - data = os.read(fd, _MAX_MARKER_SIZE + 1) - except OSError: # pragma: no cover - e.g. EAGAIN from a hostile FIFO that has a writer attached - return None + st = os.fstat(fd) + # A legitimate marker is always a regular file, so anything else at the path -- above all a FIFO -- is + # reported as a malformed marker (its mtime still drives stale eviction) without being read. Reading is + # where platforms diverge: an empty non-blocking read yields 0 bytes on Linux/macOS but EAGAIN on + # FreeBSD, and the EAGAIN used to abort the stale-break and wedge the acquire until timeout (#587). + if not stat.S_ISREG(st.st_mode): + return None, st.st_mtime + data = os.read(fd, _MAX_MARKER_SIZE + 1) + except OSError: # pragma: no cover - marker vanished or turned unreadable between open and read + return None finally: os.close(fd) return _parse_marker_bytes(data), st.st_mtime diff --git a/tests/soft_rw/test_soft_rw_sync.py b/tests/soft_rw/test_soft_rw_sync.py index a9020cac..d4967429 100644 --- a/tests/soft_rw/test_soft_rw_sync.py +++ b/tests/soft_rw/test_soft_rw_sync.py @@ -758,7 +758,7 @@ def test_stale_malformed_marker_is_evicted(lock_file: str, content: bytes) -> No def test_fifo_write_marker_does_not_block(lock_file: str) -> None: if sys.platform == "win32": - pytest.skip("os.mkfifo is unix-only") + pytest.skip("os.mkfifo is unix-only") # also narrows sys.platform so ty resolves os.mkfifo below marker = f"{lock_file}.write" os.mkfifo(marker) past = time.time() - 1000 @@ -772,6 +772,28 @@ def test_fifo_write_marker_does_not_block(lock_file: str) -> None: lock.close() +def test_fifo_write_marker_with_writer_is_evicted(lock_file: str) -> None: + if sys.platform == "win32": + pytest.skip("os.mkfifo is unix-only") # also narrows sys.platform so ty resolves os.mkfifo below + marker = f"{lock_file}.write" + os.mkfifo(marker) + past = time.time() - 1000 + os.utime(marker, (past, past)) + # A writer attached to the FIFO makes the non-blocking read raise EAGAIN on every platform, matching how a + # writerless FIFO already behaves on FreeBSD (#587). The stale marker must still be evicted by mtime, not + # read, so the acquire completes instead of timing out. + reader_fd = os.open(marker, os.O_RDONLY | os.O_NONBLOCK) + writer_fd = os.open(marker, os.O_WRONLY) + lock = _make_lock(lock_file) + try: + with lock.write_lock(timeout=2): + pass + finally: + lock.close() + os.close(writer_fd) + os.close(reader_fd) + + @pytest.mark.skipif(not hasattr(os, "O_NOFOLLOW"), reason="O_NOFOLLOW required") def test_symlinked_write_marker_is_refused(lock_file: str, tmp_path: Path) -> None: victim = tmp_path / "victim"