Skip to content

Commit 22ecd6a

Browse files
authored
don't complete a writer acquire on a peer's reclaimed marker (#571)
the writer acquire is two-phase, and in phase 2 (waiting for readers to drain) it refreshes the .write marker on every scan. if the writer is paused longer than stale_threshold a peer can evict that now-stale marker and reclaim the slot with its own token, but phase 2 carried on touching whatever sat at the path and reported success once the readers had gone, so the original writer would finish its acquire on top of the peer's live marker and two writers could end up believing they hold the exclusive lock at the same time. this re-checks that the marker still carries our token before refreshing it, the same identity check the heartbeat refresh and the release path already rely on, and re-claims the slot (waiting behind the peer when it currently holds .write) instead of trusting a foreign marker. the regression test drives the eviction mid phase-2 and asserts the acquire no longer completes on a stranger's marker.
1 parent 70ecdb2 commit 22ecd6a

2 files changed

Lines changed: 80 additions & 12 deletions

File tree

src/filelock/_soft_rw/_sync.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,40 @@ def _unlink_writer_marker_if_ours(self, token: str) -> None:
397397
return
398398
_unlink(self._paths.write)
399399

400+
def _claim_writer_marker(self, token: str) -> bool:
401+
# Claim the writer slot for ``token``. Must be called holding ``self._locks.state``. Evicts a
402+
# stale marker first, then refuses to claim while a live ``.write`` exists so a peer holding the
403+
# slot is waited out instead of overwritten.
404+
_break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time())
405+
if _file_exists(self._paths.write):
406+
return False
407+
try:
408+
_atomic_create_marker(self._paths.write, token)
409+
except FileExistsError:
410+
return False
411+
return True
412+
413+
def _touch_writer_marker_if_ours(self, token: str) -> bool:
414+
# Refresh the writer marker through a single O_NOFOLLOW fd, but only while it still carries our
415+
# token. Returns False when the marker is gone or now belongs to a peer that reclaimed the slot,
416+
# so the caller can re-claim rather than keep a stranger's marker alive. Mirrors _refresh_marker.
417+
fd = _open_marker(self._paths.write)
418+
if fd is None:
419+
return False
420+
try:
421+
try:
422+
data = os.read(fd, _MAX_MARKER_SIZE + 1)
423+
except OSError: # pragma: no cover - e.g. EAGAIN from a hostile FIFO that has a writer attached
424+
return False
425+
info = _parse_marker_bytes(data)
426+
if info is None or not hmac.compare_digest(info.token, token):
427+
return False
428+
with suppress(OSError):
429+
_touch(self._paths.write, fd=fd)
430+
return True
431+
finally:
432+
os.close(fd)
433+
400434
@classmethod
401435
def get_lock(
402436
cls,
@@ -524,21 +558,21 @@ def _acquire_writer_slot(
524558

525559
def try_claim_writer() -> bool:
526560
with self._locks.state:
527-
_break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time())
528-
if _file_exists(self._paths.write):
529-
return False
530-
try:
531-
_atomic_create_marker(self._paths.write, token)
532-
except FileExistsError:
533-
return False
534-
return True
561+
return self._claim_writer_marker(token)
535562

536563
def readers_drained_touching() -> bool:
537564
with self._locks.state:
538-
# Refresh our writer marker on every scan iteration. Otherwise phase 2 can exceed
539-
# ``stale_threshold`` under contention and a peer would treat us as stale and evict us.
540-
with suppress(OSError):
541-
_touch(self._paths.write)
565+
# Refresh our writer marker every scan iteration so phase 2 does not exceed
566+
# ``stale_threshold`` under contention and get evicted. The refresh only happens while
567+
# the marker is still ours: if we were paused past ``stale_threshold`` a peer can evict
568+
# the stale marker and reclaim ``.write`` with its own token, and touching that path
569+
# blindly would keep the peer's live marker alive and let this acquire finish as though
570+
# we still held the slot, admitting a second writer. When the claim is no longer ours we
571+
# re-claim the slot here (waiting behind the peer if it currently holds it) rather than
572+
# trusting the foreign marker, mirroring the token re-check the stale-break and release
573+
# paths already rely on.
574+
if not self._touch_writer_marker_if_ours(token) and not self._claim_writer_marker(token):
575+
return False
542576
self._break_stale_readers(time.time())
543577
return not self._any_readers()
544578

tests/soft_rw/test_soft_rw_sync.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,40 @@ def test_release_keeps_a_peers_writer_marker(lock_file: str) -> None:
625625
lock.close()
626626

627627

628+
def test_writer_phase2_does_not_complete_on_a_peers_marker(lock_file: str, monkeypatch: pytest.MonkeyPatch) -> None:
629+
# If a writer is paused past stale_threshold during phase 2 (waiting for readers to drain) a peer can
630+
# evict its stale marker and reclaim .write with its own token. Phase 2 must notice the foreign marker
631+
# rather than keep touching it and completing the acquire as if we still held the slot, which would let
632+
# two writers run at once. A live reader keeps phase 2 looping; on the first poll sleep we simulate the
633+
# eviction by overwriting .write with a peer token and draining the reader.
634+
reader = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40)
635+
reader.acquire_read(timeout=2)
636+
writer = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40)
637+
write_marker = f"{lock_file}.write"
638+
peer_marker = b"a" * 32 + b"\n1\npeerhost\n"
639+
real_sleep = time.sleep
640+
swapped = threading.Event()
641+
642+
def hook(seconds: float) -> None: # noqa: ARG001
643+
if not swapped.is_set():
644+
swapped.set()
645+
Path(write_marker).write_bytes(peer_marker)
646+
reader.release()
647+
real_sleep(0.005)
648+
649+
monkeypatch.setattr(sync_mod.time, "sleep", hook)
650+
try:
651+
with pytest.raises(Timeout):
652+
writer.acquire_write(timeout=0.6)
653+
assert swapped.is_set()
654+
# The peer's live marker was never overwritten or kept alive by us.
655+
assert Path(write_marker).read_bytes() == peer_marker
656+
finally:
657+
monkeypatch.setattr(sync_mod.time, "sleep", real_sleep)
658+
writer.close()
659+
reader.close()
660+
661+
628662
def test_heartbeat_survives_transient_touch_error(lock_file: str, monkeypatch: pytest.MonkeyPatch) -> None:
629663
# On the NFS-style filesystems this lock targets a transient ESTALE / EIO on the heartbeat touch is
630664
# routine; it must not kill the heartbeat and silently drop the lease while we still believe we hold it.

0 commit comments

Comments
 (0)