diff --git a/docs/changelog/843.bugfix.rst b/docs/changelog/843.bugfix.rst new file mode 100644 index 00000000..043e84de --- /dev/null +++ b/docs/changelog/843.bugfix.rst @@ -0,0 +1,14 @@ +Fix :class:`~filelock.SoftFileLock`, :class:`~filelock.MarkerSoftFileLock` and :class:`~filelock.SoftFileLease` removing +or displacing a live holder's marker in five cases. ext4 gave a freed inode number to the next marker. A stalled +:class:`~filelock.FileLock` removed its ``ENOSYS`` placeholder after a peer replaced it. A failed acquire cleaned up +after a successor took the path. A stale break left the lock path empty while it checked the marker it had moved. A +``lifetime`` break judged a marker by a reused inode number. On Linux and macOS a stale marker now changes hands in one +atomic exchange. On other platforms without one, except Windows, including NFS and SMB, the marker stays in place and +waits for ``timeout`` or ``break_lock()``. Restoring a marker no longer follows a symlink found under the break name. A +holder that releases while a peer's exchange has its marker aside leaves a record no lock parses, so the put-back lands +a marker that ages out. A lease heartbeat that finds another file at the lock path once, while a peer's break has its +marker aside, waits for the next refresh before it reports a compromise. A contender that finds a dead breaker's marker +puts back the marker that breaker left under its break name. A marker is the breaker's own only when it names the +breaker's process on this host; the same process number on another host is a holder. A contender takes a stranded marker +under a name of its own before it puts the marker back, so two contenders no longer reverse each other's exchange and +remove the live holder's marker. A claim left behind by a failed put-back is retried on the next poll. diff --git a/docs/concepts.rst b/docs/concepts.rst index 1c7c75b8..446c6e67 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -230,10 +230,10 @@ Losing a private name makes the publisher retry; it never converts an unpublishe exclusion; remove the file only after the complete lock protocol has stopped using it. Filesystems without ``flock``: when ``flock`` fails with ``ENOSYS``, the lock switches to :class:`SoftFileLock - ` and emits a warning, unless constructed with ``fallback_to_soft=False``. Before switching it - removes the empty file only if this acquire created it. A file that already existed may be another process's soft - marker, so it stays: the soft lock waits on a live holder and reclaims an empty leftover once it has stayed - unchanged for two seconds. + ` and emits a warning, unless constructed with ``fallback_to_soft=False``. Before switching + it removes the empty file only if this acquire created it. It does so while still holding the file open, with the + exchange a stale break uses, so a peer's marker that replaced it is put back rather than deleted. A file that + already existed may be another process's soft marker, so it stays and the soft lock waits on it. **Other platforms without fcntl** Falls back to :class:`SoftFileLock ` and emits a warning. The lock is not enforced by the OS, @@ -596,10 +596,15 @@ Malformed and legacy markers A soft lock treats a record it cannot parse as malformed, not as a holder. A plain :class:`SoftFileLock ` self-heals a malformed marker once it has watched it stay unchanged for a short grace window on -its own monotonic clock, which absorbs the brief gap between creating a marker and writing its record. A strict lock -never age-breaks, so it fails closed on an unreadable claim. A marker written by an older filelock stays readable: the -process start token is an integer, so a 3.29 reader parses a newer marker as well-formed and stays conservative rather -than evicting a live holder. +its own monotonic clock, which absorbs the brief gap between creating a marker and writing its record. + +It tells markers apart by modification time, inode number and change time (the time the kernel last changed the file's +metadata). An owner can set a file's modification time, and ext4 hands a freed inode number to the next file, but no +process can set the change time. A new marker therefore never inherits the time spent watching an old one. + +A strict lock never age-breaks, so it fails closed on an unreadable claim. A marker written by an older filelock stays +readable: the process start token is an integer, so a 3.29 reader parses a newer marker as well-formed and stays +conservative rather than evicting a live holder. Filesystem support matrix ========================= @@ -692,19 +697,39 @@ container runtimes create, keeps the token. A process that cannot read ``/proc/s ``ProcSubset=pid`` hides it) records ``starttime`` without the boot id. A contender then compares ``starttime`` alone whenever either side lacks the boot id. -Breaking a marker leaves a short window. Right before breaking it the contender reads the marker's inode and -modification time again and leaves it alone if they moved since the stale check, the way Mercurial re-reads a lock under -its break lock before removing it. It then renames the marker to a private ``.break.*`` name, so only one of -several breakers takes it, and checks the renamed file's inode and modification time once more. A mismatch means a peer -recreated the marker between that last read and the rename, so the contender hard-links that live marker back to the -lock path and drops the break name. A third process that takes the empty path in the two syscalls between the rename -and the link keeps it, whether a soft lock creating its marker or a :class:`StrictSoftFileLock -` publishing its sentinel, and the live marker stays under the break name. Both then hold -the lock. Mercurial -closes this window by serializing breakers on a separate ``.break`` lock; filelock does not, because a breaker that -crashes would leave that lock needing stale detection of its own and a 3.29 peer never takes it. Where ``os.link`` is -missing or the filesystem refuses hard links, the live marker stays under the break name. The same applies to the -``lifetime`` and :class:`SoftFileLease ` expiry breaks. +A contender never leaves the lock path empty while it breaks a marker. Right before breaking it reads the marker's inode +(the kernel's identity for a file, independent of its name) and modification time again. If they moved since the stale +check, it leaves the marker alone, the way Mercurial re-reads a lock under its break lock before removing it. On Linux +(``renameat2``) and macOS (``renamex_np``) it then writes a complete marker of its own under a private +``.break.*`` name and exchanges the two names in one step. A contender that polls meanwhile finds a marker and +waits. + +If the file it took is the one judged stale, it removes that file and holds the lock from then on. A peer that +exchanges after that moves the holder's marker aside only while it checks what it took, and puts it back. A holder that +releases while its marker is aside leaves a record no lock parses, so the put-back lands a marker that ages out. +Otherwise a peer recreated the marker, and a second exchange puts the live marker back before its own is removed. + +The private name is ``.break..``: the breaker's PID, then 32 random hex digits. A breaker killed +between its exchange and the put-back leaves its own marker at the lock path and a live holder's marker under that name. +A contender that finds the breaker's marker dead first looks in the lock's directory for names that start with +``.break..``, where ```` is the dead breaker's. It first renames the record to +``.restore..`` with its own PID, so only one of several contenders acts on it; a record whose +recoverer died there is claimable again, and a claim a failed put-back left in the same process is retried on the next +poll. It then exchanges back a marker whose owner is alive or cannot be proven dead, and removes one whose owner is +dead. Where the filesystem refuses that exchange, the lock counts as held until ``timeout`` or ``break_lock()``. The +placeholder a break writes while it works carries the same PID, so a contender that evicts a placeholder a killed +process left finds the same names. + +The contender holds the judged marker open from before it reads the record until the break ends. ext4 gives a freed +inode number to the next file created, so a successor's marker could otherwise carry the number of the marker judged +stale. + +Windows has no such exchange, but it refuses to rename a marker its live holder keeps open. There the contender renames +the marker aside, checks it, and links back a marker it took by mistake; ``link`` never replaces a name. Anywhere else +without an exchange, including NFS and SMB, which refuse it, a contender does not move a marker at all, because the path +would stand empty beside a possibly live holder. The marker then counts as held until ``timeout`` or ``break_lock()``. +The ``lifetime`` and :class:`SoftFileLease ` expiry breaks follow the same rules, and a failed +acquire removes its own marker by the same exchange before it closes it. A PID means something only inside its PID namespace, and two containers in one pod share the hostname and the boot id without sharing one. On Linux the recorded hostname therefore names the namespace too, as ``?pidns-`` with @@ -718,13 +743,14 @@ host's namespace reads such a marker from a container that shares the hostname ( names another process or none. Before it calls that owner dead it looks for a task in another namespace whose innermost PID (``NSpid`` in ``/proc//status``) and start token match the record, since ``starttime`` and the boot id read the same in every PID namespace, and keeps the marker if one exists. From a time namespace with a boottime offset it matches -the innermost PID alone. Three limits remain. A marker without a start token, a kernel before -4.1 without ``NSpid``, or a ``/proc`` this process cannot list leaves the check nothing to find. Where -``/proc/self/ns/pid`` cannot be read, the bare hostname is all that separates namespaces. And a contender never reclaims -a marker from another namespace: a container that crashes and is replaced comes back in a new namespace. A -:class:`~filelock.Timeout` names such a holder. Containers that can crash while holding a soft lock should use -:class:`SoftFileLease `, set ``lifetime``, or call ``break_lock()`` at entrypoint once nothing -else can hold the path. +the innermost PID alone. + +Three limits remain. A marker without a start token, a kernel before 4.1 without ``NSpid``, or a ``/proc`` this process +cannot list leaves the check nothing to find. Where ``/proc/self/ns/pid`` cannot be read, the bare hostname is all that +separates namespaces. And a contender never reclaims a marker from another namespace: a container that crashes and is +replaced comes back in a new namespace. A :class:`~filelock.Timeout` names such a holder. Containers that can crash +while holding a soft lock should use :class:`SoftFileLease `, set ``lifetime``, or call +``break_lock()`` at entrypoint once nothing else can hold the path. Why is ReadWriteLock backed by SQLite? ====================================== diff --git a/docs/how-to.rst b/docs/how-to.rst index 16acfd7f..8441309e 100644 --- a/docs/how-to.rst +++ b/docs/how-to.rst @@ -812,8 +812,9 @@ Every platform stores a process start token in the marker to guard against PID r them unchanged for two seconds. That recovery path is not fail closed. -A contender breaking a stale marker while a peer recreates it can admit a third holder, soft or strict, in a window of -two syscalls; see :ref:`concepts:How does stale lock detection work across platforms?` for the details. +A contender breaks a stale marker without the lock path ever standing empty. Where it cannot (NFS, SMB and platforms +without an atomic exchange, other than Windows) it leaves the marker in place; see :ref:`concepts:How does stale lock +detection work across platforms?` for the details. ******************************** Use fail-closed soft locks diff --git a/src/filelock/_api.py b/src/filelock/_api.py index d0b77584..9e46be4e 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -38,7 +38,7 @@ from weakref import WeakKeyDictionary, WeakValueDictionary, ref from filelock._error import SoftFileLockLifetimeWarning, Timeout -from filelock._util import break_lock_file, ensure_directory_exists +from filelock._util import ensure_directory_exists #: No explicit file permission mode was passed. Lock files then open with 0o666 so umask and default ACLs pick #: the final permissions, and fchmod is skipped to preserve POSIX default ACL inheritance. @@ -1665,18 +1665,7 @@ def _commit_release(self) -> None: self._context.claim_root = None def _try_break_expired_lock(self) -> None: - """Remove the lock file if its modification time exceeds the configured :attr:`lifetime`.""" - if (lifetime := self._context.lifetime) is None: - return - with contextlib.suppress(OSError): - # lstat, not stat: an attacker with write access to the lock directory can replace a held - # lock file with a symlink pointing at an old file, making stat() report the target's stale - # mtime so a waiter breaks a live lock and two processes hold it at once. lstat reads the - # symlink's own mtime, matching the O_NOFOLLOW reads elsewhere. - st = os.lstat(self._lock_path) - if time.time() - st.st_mtime < lifetime: - return - break_lock_file(self._lock_path, st.st_mtime, st.st_ino) + """Remove the lock file once it is older than :attr:`lifetime`; only an existence lock accepts one.""" def _check_give_up( self, diff --git a/src/filelock/_lease.py b/src/filelock/_lease.py index d9733487..60187bb2 100644 --- a/src/filelock/_lease.py +++ b/src/filelock/_lease.py @@ -28,7 +28,7 @@ from filelock._identity import owner_is_stale from filelock._marker import MarkerSoftFileLock, OwnerMode, OwnerRecord, parse_marker from filelock._soft import _read_lock_file -from filelock._util import break_lock_file, touch +from filelock._util import pin_inode, touch if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -239,6 +239,12 @@ def _published_record(self) -> OwnerRecord: return super()._published_record()._replace(token=self._claim.token, lease_duration=self._lease_duration) def _try_break_stale_lock(self) -> None: + # A break can fail for reasons a contender must ride out rather than raise on: a peer broke the marker first, + # or Windows refuses to rename a file whose holder still has it open. Poll again instead. + with suppress(OSError), pin_inode(self._lock_path) as pinned: + self._try_break_pinned_lock(pinned) + + def _try_break_pinned_lock(self, pinned: int | None) -> None: if (peer := self._read_peer()) is None: # Not a readable protocol 2 lease record: a partial write, a foreign or legacy protocol 1 marker, or the # strict sentinel. The base self-heal evicts a genuinely malformed marker once it ages past the grace @@ -246,7 +252,9 @@ def _try_break_stale_lock(self) -> None: # every lease contender until its own timeout. super()._try_break_stale_lock() return - owner, mtime, ino = peer + owner, marker = peer + if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing + return # replaced before the read, so nothing keeps the number read from reuse # Only a peer that published a lease agreed to be superseded by age; an exclusive owner is reclaimed only once # it is provably dead, and a contract this version does not know never is. Raise the mismatch outside the read # so the suppression cannot swallow it. @@ -256,22 +264,19 @@ def _try_break_stale_lock(self) -> None: f"{self._lease_duration!r}s; every contender for a path must agree on lease_duration" ) raise LeaseSettingsMismatch(msg) - # A break can fail for reasons a contender must ride out rather than raise on: a peer broke the marker first, - # or Windows refuses to rename a file whose holder still has it open. Poll again instead. - with suppress(OSError): - # A dead or recycled owner is reclaimed at once, whether it held a lease or an exclusive marker; a live - # lease owner whose marker stops being refreshed is superseded on the schedule every contender agreed to. - if owner.mode != "unknown" and owner_is_stale(owner.pid, owner.hostname, owner.start): - break_lock_file(self._lock_path, mtime, ino) - return - if owner.mode == "lease" and self._marker_unchanged_for(mtime, ino) >= self._lease_duration: - break_lock_file(self._lock_path, mtime, ino) - - def _read_peer(self) -> tuple[OwnerRecord, float, int] | None: + # A dead or recycled owner is reclaimed at once, whether it held a lease or an exclusive marker; a live lease + # owner whose marker stops being refreshed is superseded on the schedule every contender agreed to. + if owner.mode != "unknown" and owner_is_stale(owner.pid, owner.hostname, owner.start): + self._take_over(marker, owner.pid) + return + if owner.mode == "lease" and self._marker_unchanged_for(marker) >= self._lease_duration: + self._take_over(marker, None) + + def _read_peer(self) -> tuple[OwnerRecord, os.stat_result] | None: with suppress(OSError, ValueError): - content, mtime, ino = _read_lock_file(self._lock_path) + content, marker = _read_lock_file(self._lock_path) if (owner := parse_marker(content)) is not None: - return owner, mtime, ino + return owner, marker return None def _start_heartbeat(self, claim: _LeaseClaim, fd: int, identity: tuple[int, int], token: str) -> None: @@ -347,8 +352,9 @@ def run(self) -> None: if outcome == "ok": last_success, missing = time.monotonic(), False continue - # A peer's stale break moves a live marker aside for two syscalls, so only a second miss in a row is a loss. - if outcome == "marker-missing" and not missing: + # A peer's stale break exchanges a live marker aside for the length of its check, so only a second miss or + # change in a row is a loss. + if outcome in {"marker-missing", "owner-changed"} and not missing: missing = True continue if outcome == "transient" and time.monotonic() - last_success < self.duration - self.interval: diff --git a/src/filelock/_marker.py b/src/filelock/_marker.py index 5e6c47e9..2a7c694c 100644 --- a/src/filelock/_marker.py +++ b/src/filelock/_marker.py @@ -10,7 +10,7 @@ from filelock._identity import host_name, owner_is_current_process, owner_is_stale, process_start_token from filelock._soft import SoftFileLock, _read_lock_file, parse_decimal -from filelock._util import break_lock_file, write_all +from filelock._util import pin_inode, write_all #: Protocol 1 is the legacy ``\n\n[\n]`` marker that :class:`SoftFileLock` still writes. #: Protocol 2 carries the owner mode and the lease claim. A protocol 1 reader treats a protocol 2 marker as malformed @@ -74,13 +74,15 @@ def force_break(self) -> None: self.break_lock() def _try_break_stale_lock(self) -> None: - with suppress(OSError, ValueError): - snapshot: Final[tuple[str | None, float, int]] = _read_lock_file(self._lock_path) + with suppress(OSError, ValueError), pin_inode(self._lock_path) as pinned: + content, marker = _read_lock_file(self._lock_path) owner: Final[OwnerRecord | None] - if (owner := parse_marker(snapshot[0])) is None: + if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing + return # replaced before the read, so nothing keeps the number read from reuse + if (owner := parse_marker(content)) is None: super()._try_break_stale_lock() elif owner.mode != "unknown" and owner_is_stale(owner.pid, owner.hostname, owner.start): - break_lock_file(self._lock_path, *snapshot[1:]) + self._take_over(marker, owner.pid) @classmethod def _recorded_holder(cls, content: str | None) -> tuple[int, str] | None: @@ -88,6 +90,15 @@ def _recorded_holder(cls, content: str | None) -> tuple[int, str] | None: return owner.pid, owner.hostname return super()._recorded_holder(content) + @classmethod + def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: # pragma: win32 no cover + if (owner := parse_marker(content)) is None: + return super()._marker_is_live(content, breaker_pid) + return owner.mode == "unknown" or ( + (owner.pid != breaker_pid or owner.hostname != host_name()) + and not owner_is_stale(owner.pid, owner.hostname, owner.start) + ) + def _read_owner(self) -> OwnerRecord | None: with suppress(OSError, ValueError): return parse_marker(_read_lock_file(self._lock_path)[0]) diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py index 08afe98b..aeadfdfb 100644 --- a/src/filelock/_soft.py +++ b/src/filelock/_soft.py @@ -7,12 +7,17 @@ "filelock._util", "itertools", "pathlib", + "re", + "secrets", "typing", ] import os +import re +import secrets import stat import sys +import threading import time from contextlib import suppress from errno import EACCES, EEXIST, EPERM @@ -23,7 +28,15 @@ from filelock._api import BaseFileLock, _raise_grouped_errors from filelock._identity import host_name, owner_is_current_process, owner_is_stale, process_start_token from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD -from filelock._util import break_lock_file, raise_on_not_writable_file, write_all +from filelock._util import ( + break_lock_file, + exchange_names, + exchange_supported, + pin_inode, + raise_on_not_writable_file, + remove_own_file, + write_all, +) MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0 _MAX_LOCK_FILE_SIZE: Final[int] = 1024 @@ -31,6 +44,8 @@ _MARKER_WITH_START_TOKEN_LINE_COUNT: Final[int] = 3 #: Contenders act on the record in the marker, so no other account may write it, whatever the mode or umask. _OTHERS_WRITE: Final[int] = stat.S_IWGRP | stat.S_IWOTH +_CLAIMS_IN_PROGRESS: Final[set[str]] = set() +_CLAIMS_LOCK: Final[threading.Lock] = threading.Lock() class SoftFileLock(BaseFileLock): @@ -61,8 +76,8 @@ class SoftFileLock(BaseFileLock): #: An existence lock keeps protocol state in its marker, so it cannot lend the descriptor to an on_acquired hook. _on_acquired_supported: bool = False - #: The marker's (mtime, inode) when this instance last saw it change, and when that was on its monotonic clock. - _marker_seen: tuple[tuple[float, int], float] | None = None + #: The marker's (mtime, inode, ctime) when this instance last saw it change, and when, on its monotonic clock. + _marker_seen: tuple[tuple[float, int, int], float] | None = None #: The identity of a marker a Windows release relinquished but has not unlinked yet. _marker_cleanup: tuple[int, int] | None = None @@ -101,9 +116,17 @@ def _acquire(self) -> None: except BaseException: self._mark_descriptor_released() try: - os.close(fd) + if sys.platform != "win32": # pragma: win32 no cover + # Before the close: our open descriptor keeps the inode allocated, so a successor's marker cannot + # carry its number. + with suppress(OSError): + remove_own_file(self._lock_path, fd) finally: - self._unlink_held_marker(identity) + try: + os.close(fd) + finally: + if sys.platform == "win32": # pragma: win32 cover + self._unlink_held_marker(identity) raise def _apply_explicit_mode(self, fd: int) -> None: @@ -114,8 +137,10 @@ def _apply_explicit_mode(self, fd: int) -> None: os.fchmod(fd, self._context.mode & ~_OTHERS_WRITE) def _try_break_stale_lock(self) -> None: - with suppress(OSError, ValueError): - content, mtime, ino = _read_lock_file(self._lock_path) + with suppress(OSError, ValueError), pin_inode(self._lock_path) as pinned: + content, marker = _read_lock_file(self._lock_path) + if pinned not in {None, marker.st_ino}: # pragma: win32 no cover # Windows pins nothing + return # another node replaced the pinned one before the read, and nothing keeps its number from reuse if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link return holder = _parse_lock_holder(content) @@ -124,19 +149,175 @@ def _try_break_stale_lock(self) -> None: # Unparsable: wrong line count, a non-integer PID or start token, empty, oversized or not UTF-8. # Self-heal only once the file is clearly not a half-written fresh lock (a peer between O_EXCL and # _write_lock_info), so the brief create-then-write window is never mistaken for a stale lock. - if self._marker_unchanged_for(mtime, ino) >= MALFORMED_LOCK_AGE_THRESHOLD: - break_lock_file(self._lock_path, mtime, ino) + if self._marker_unchanged_for(marker) >= MALFORMED_LOCK_AGE_THRESHOLD: + self._take_over(marker, _placeholder_breaker(content)) return if owner_is_stale(*holder): - break_lock_file(self._lock_path, mtime, ino) + self._take_over(marker, holder[0]) + + def _try_break_expired_lock(self) -> None: + if (lifetime := self._context.lifetime) is None: + return + with suppress(OSError), pin_inode(self._lock_path) as pinned: + # _read_lock_file takes the lstat, not stat: a symlink swapped in at the path must report its own mtime. + content, marker = _read_lock_file(self._lock_path) + if pinned not in {None, marker.st_ino} or time.time() - marker.st_mtime < lifetime: + return + holder = self._recorded_holder(content) + self._take_over(marker, None if holder is None else holder[0]) + + def _take_over(self, marker: os.stat_result, breaker_pid: int | None) -> None: + # Swap the judged marker for a complete marker of ours in one exchange, so the lock path never stands empty. + # Once the file swapped out proves to be the one judged, this lock holds the path; the caller's pin keeps that + # file's inode number from naming anything else. Only a peer's exchange can move our marker after that, and + # the peer puts it back because what it took is not the file it judged. Every failure before the handoff takes + # our marker off the path and puts back what the exchange took. Without an exchange, the portable break decides. + if exchange_supported(): # pragma: win32 no cover + self._take_over_by_exchange(marker, breaker_pid) + else: + break_lock_file(self._lock_path, marker.st_mtime, marker.st_ino) + + def _take_over_by_exchange(self, marker: os.stat_result, breaker_pid: int | None) -> None: # pragma: win32 no cover + if (current := os.lstat(self._lock_path)).st_mtime != marker.st_mtime or current.st_ino != marker.st_ino: + return + if breaker_pid is not None and self._restore_stranded_marker(breaker_pid, marker): + return + aside: Final = f"{self._lock_path}.break.{os.getpid()}.{secrets.token_hex(16)}" + flags: Final = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + fd: Final = os.open(aside, flags, self._open_mode() & ~_OTHERS_WRITE) + self._mark_descriptor_pending(fd) + exchanged = held = False + try: + identity: Final = _file_identity(os.fstat(fd)) + self._apply_explicit_mode(fd) + self._write_lock_info(fd) + if not exchange_names(aside, self._lock_path): + return # NFS, SMB and the like refuse it; renaming the marker aside would empty the path + exchanged = True + if (taken := os.lstat(aside)).st_mtime != marker.st_mtime or taken.st_ino != marker.st_ino: + return # a peer recreated the marker, which the cleanup below swaps back + Path(aside).unlink() + exchanged = False + self._mark_descriptor_owned(fd, identity) + held = True + finally: + if not held: + self._abandon_take_over(fd, aside, put_back=exchanged) + + def _restore_stranded_marker(self, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover + # A breaker killed between its exchange and the put-back leaves its own marker at the path and the live + # holder's marker under its break name. A recoverer first renames that record to a name of its own. Only one + # contender wins the rename, so two of them never exchange the same record and reverse each other. A record + # a dead recoverer holds is claimable again. A claim of this process that no thread is working on was + # abandoned by a failed put-back, and the next poll takes it up again. Where the marker cannot be put back + # the lock counts as held, as it does wherever no exchange is possible. + if (candidates := self._stranded_candidates(breaker_pid)) is None: + return True + return any(self._recover_claimed(path, breaker_pid, marker, adopt=abandoned) for path, abandoned in candidates) + + def _stranded_candidates(self, breaker_pid: int) -> list[tuple[str, bool]] | None: # pragma: win32 no cover + directory, name = os.path.split(self._lock_path) + stranded: Final = re.compile(rf"{re.escape(name)}\.break\.{breaker_pid}\.[0-9a-f]{{32}}") + claimed: Final = re.compile(rf"{re.escape(name)}\.restore\.(\d+)\.[0-9a-f]{{32}}") + candidates: list[tuple[str, bool]] = [] + with os.scandir(directory or os.curdir) as entries: + for entry in entries: + if not entry.is_file(follow_symlinks=False): + continue + path = str(Path(directory) / entry.name) + if stranded.fullmatch(entry.name): + candidates.append((path, False)) + elif found := claimed.fullmatch(entry.name): + ours = int(found[1]) == os.getpid() + if not (ours or owner_is_stale(int(found[1]), host_name(), None)): + return None + candidates.append((path, ours)) + return sorted(candidates) + + def _recover_claimed( + self, path: str, breaker_pid: int, marker: os.stat_result, *, adopt: bool + ) -> bool: # pragma: win32 no cover + # One try/finally owns the registration from the first step to the last, so no interruption can leave it set. + # Registered before the rename publishes the name, so a thread that scans meanwhile sees a claim in progress. + own: Final = path if adopt else f"{self._lock_path}.restore.{os.getpid()}.{secrets.token_hex(16)}" + if not _begin_claim(own): + return True + try: + if not adopt: + try: + Path(path).rename(own) + except OSError: # a peer that won the rename now holds the record, so the lock counts as held + return True + return self._restore_claimed(own, breaker_pid, marker) + finally: + _end_claim(own) + + def _restore_claimed(self, own: str, breaker_pid: int, marker: os.stat_result) -> bool: # pragma: win32 no cover + try: + content, _ = _read_lock_file(own) + except OSError: + self._return_claim(own, breaker_pid) + return True + if not self._marker_is_live(content, breaker_pid): + with suppress(OSError): + Path(own).unlink() + return False + # The inode of the marker judged dead must still be at the path, or a peer has acted since. + with suppress(OSError): + current = os.lstat(self._lock_path) + if (current.st_ino, current.st_mtime) == (marker.st_ino, marker.st_mtime) and exchange_names( + own, self._lock_path + ): + if os.lstat(own).st_ino == marker.st_ino: + Path(own).unlink() + return True + # A peer replaced the path after the check, so the exchange took its marker: swap it straight back. + exchange_names(own, self._lock_path) + self._return_claim(own, breaker_pid) + return True + + def _return_claim(self, own: str, breaker_pid: int) -> None: # pragma: win32 no cover + # A refused rename leaves the claim under this process's PID, and the next poll retries it. + with suppress(OSError): + Path(own).rename(f"{self._lock_path}.break.{breaker_pid}.{secrets.token_hex(16)}") + + @classmethod + def _marker_is_live(cls, content: str | None, breaker_pid: int) -> bool: # pragma: win32 no cover + # The breaker's own marker names its PID on this host; the same number on another host is a holder. + if content == STRICT_SOFT_SENTINEL_RECORD: # pragma: needs hard-link + return True + return ( + (holder := _parse_lock_holder(content)) is not None + and (holder[0] != breaker_pid or holder[1] != host_name()) + and not owner_is_stale(*holder) + ) + + def _abandon_take_over(self, fd: int, aside: str, *, put_back: bool) -> None: # pragma: win32 no cover + self._mark_descriptor_released() + try: + if put_back: + exchange_names(aside, self._lock_path) + finally: + try: + # Whichever name still holds our marker loses it: the descriptor keeps its inode number ours. + own: Final = os.fstat(fd) + for name in (self._lock_path, aside): + with suppress(FileNotFoundError): + if os.path.samestat(os.lstat(name), own): + Path(name).unlink() + finally: + os.close(fd) - def _marker_unchanged_for(self, mtime: float, ino: int) -> float: + def _marker_unchanged_for(self, marker: os.stat_result) -> float: # The mtime is the writer's or file server's clock, so against time.time() a contender whose clock runs ahead - # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. + # would expire a fresh marker. Only watch it for change, timed on this process's monotonic clock. The owner of a + # file sets its mtime, and ext4 reuses a freed inode number, so a new marker can repeat both. The change time + # cannot be set and moves with every create, which keeps a new marker from inheriting an old one's age. now: Final = time.monotonic() - if (seen := self._marker_seen) is None or seen[0] != (mtime, ino): - self._marker_seen = (mtime, ino), now + key: Final = marker.st_mtime, marker.st_ino, marker.st_ctime_ns + if (seen := self._marker_seen) is None or seen[0] != key: + self._marker_seen = key, now return 0.0 return now - seen[1] @@ -215,7 +396,7 @@ def _release(self) -> None: else: # pragma: win32 no cover # Our open descriptor keeps the inode allocated, so its number cannot name a successor's marker yet, and an # interrupt before the unlink lands leaves the lock held for the retried release to finish. - unlinked: Final = self._unlink_open_marker(identity) + unlinked: Final = self._unlink_open_marker(fd, identity) # A failed close may already have released and recycled the descriptor number. Relinquish it before the # one close attempt so no later release can close an unrelated descriptor that reused the same integer. self._mark_descriptor_released() @@ -254,17 +435,25 @@ def _marker_names(self) -> tuple[str, ...]: # still reaches the marker this hold opened. return tuple(dict.fromkeys((self._lock_path, self.lock_file))) - def _unlink_open_marker(self, identity: tuple[int, int] | None) -> bool: # pragma: win32 no cover + def _unlink_open_marker(self, fd: int, identity: tuple[int, int] | None) -> bool: # pragma: win32 no cover if identity is None: return True - try: - for name in self._marker_names(): - with suppress(FileNotFoundError): - if _file_identity(os.lstat(name)) == identity: - Path(name).unlink() - return True - except OSError: - return False + replaced = False + for name in self._marker_names(): + try: + if _file_identity(os.lstat(name)) == identity: + Path(name).unlink() + return True + replaced = True + except FileNotFoundError: + continue + except OSError: + return False + if replaced: + try: + _leave_released_record(fd) + except OSError: + return False return True def _finish_marker_cleanup(self) -> None: @@ -291,10 +480,9 @@ def _close_marker_fd(self) -> None: # pragma: win32 cover raise def _unlink_held_marker(self, identity: tuple[int, int] | None) -> None: - if identity is None: - return if sys.platform == "win32": # pragma: win32 cover - self._windows_unlink_if_ours(identity) + if identity is not None: + self._windows_unlink_if_ours(identity) else: # pragma: win32 no cover for name in self._marker_names(): with suppress(OSError): @@ -334,14 +522,46 @@ def _file_identity(st: os.stat_result) -> tuple[int, int]: return st.st_dev, st.st_ino -def _read_lock_file(path: str) -> tuple[str | None, float, int]: +def _begin_claim(name: str | Path) -> bool: # pragma: win32 no cover + # Keyed by the file name alone: its random token identifies the claim, and the spelling of the directory varies. + with _CLAIMS_LOCK: + if (key := Path(name).name) in _CLAIMS_IN_PROGRESS: + return False + _CLAIMS_IN_PROGRESS.add(key) + return True + + +def _end_claim(name: str | Path) -> None: # pragma: win32 no cover + with _CLAIMS_LOCK: + _CLAIMS_IN_PROGRESS.discard(Path(name).name) + + +def _placeholder_breaker(content: str | None) -> int | None: # pragma: win32 no cover + # The placeholder a break leaves at the path while it works names the breaker that may have died there. + if content is None or not content.startswith(placeholder := "filelock/break\n") or not content.endswith("\n"): + return None + with suppress(ValueError): + return parse_decimal(content.removeprefix(placeholder)[:-1]) + return None + + +def _leave_released_record(fd: int) -> None: + # A peer's exchange holds our marker under its break name and will put it back. Overwriting the record lets + # contenders age it out instead of waiting on this live PID. The write comes before the truncate so the file is + # never empty. + if sys.platform != "win32": # pragma: win32 no cover + os.pwrite(fd, released := b"filelock/released\n", 0) + os.ftruncate(fd, len(released)) + + +def _read_lock_file(path: str) -> tuple[str | None, os.stat_result]: # A legitimate lock file is always a regular file. Classify the path with lstat first, so any other node (symlink, # FIFO, socket, device) is reported as a malformed lock the caller can evict, without an os.open that would follow - # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The mtime and inode still flow back + # a symlink, stall on a FIFO, or fail on a socket and leave acquisition wedged. The stat result still flows back # for the identity-checked stale break. lstat, not stat, so a hostile symlink is never followed onto its target. st = os.lstat(path) if not stat.S_ISREG(st.st_mode): # pragma: needs fifo - return None, st.st_mtime, st.st_ino + return None, st # Re-check on the opened handle: O_NOFOLLOW refuses a symlink swapped in after the lstat, O_NONBLOCK stops a FIFO # swapped in from stalling the open, and the fstat catches any other non-regular replacement race before we read. # The capped read stops a huge regular file (e.g. one filled from /dev/zero) from exhausting memory. @@ -349,14 +569,14 @@ def _read_lock_file(path: str) -> tuple[str | None, float, int]: try: st = os.fstat(fd) if not stat.S_ISREG(st.st_mode): # pragma: no cover # only a non-regular node swapped in after the lstat - return None, st.st_mtime, st.st_ino + return None, st data = os.read(fd, _MAX_LOCK_FILE_SIZE + 1) finally: os.close(fd) if len(data) <= _MAX_LOCK_FILE_SIZE: with suppress(UnicodeDecodeError): - return data.decode("utf-8"), st.st_mtime, st.st_ino - return None, st.st_mtime, st.st_ino + return data.decode("utf-8"), st + return None, st def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None: diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py index 274c4833..14996b2a 100644 --- a/src/filelock/_unix.py +++ b/src/filelock/_unix.py @@ -1,6 +1,6 @@ from __future__ import annotations -__lazy_modules__ = ["contextlib", "pathlib", "typing", "warnings"] +__lazy_modules__ = ["contextlib", "filelock._util", "pathlib", "typing", "warnings"] import os import sys @@ -11,6 +11,7 @@ from typing import Final, cast from filelock._api import BaseFileLock +from filelock._util import remove_own_file has_fcntl = False if sys.platform == "win32": # pragma: win32 cover @@ -124,23 +125,22 @@ def _open_lock_file(self) -> tuple[int, bool] | None: return None # unlinked since the create attempt; let the retry loop start over def _switch_to_soft_lock(self, fd: int, missing_flock: OSError, *, created: bool) -> None: - # The filesystem does not implement flock. Capture the opened file's identity before closing so the cleanup - # below removes only this attempt's placeholder, not a peer's replacement. - identity: tuple[int, int] | None = None - with suppress(OSError): - identity = (fstat := os.fstat(fd)).st_dev, fstat.st_ino - self._mark_descriptor_released() - os.close(fd) - if not self._fallback_to_soft or self._preserve_lock_file or self._on_acquired is not None: - # Fail closed: the caller opted out of existence-lock semantics (#603), asked to preserve the pathname - # (#605), or set an on_acquired hook (#607), none of which a soft lock can honor. + # The filesystem does not implement flock. Fail closed when the caller opted out of existence-lock + # semantics (#603), asked to preserve the pathname (#605), or set an on_acquired hook (#607), none of which + # a soft lock can honor. + falls_back: Final = self._fallback_to_soft and not self._preserve_lock_file and self._on_acquired is None + try: + # A file this attempt did not create may be a peer's live marker; the soft lock ages out a stale empty + # one. Our own placeholder goes before the close: the open descriptor keeps its inode number from a + # peer's marker, and an exchange puts back a peer's marker that replaced it. + if created and falls_back: + with suppress(OSError): + remove_own_file(self._lock_path, fd) + finally: + self._mark_descriptor_released() + os.close(fd) + if not falls_back: raise missing_flock - # A file this attempt did not create may be a peer's live marker; the soft lock ages out a stale empty one. - if created: - with suppress(OSError): - current = os.lstat(self._lock_path) - if identity == (current.st_dev, current.st_ino): - Path(self._lock_path).unlink() self._fallback_to_soft_lock() self._acquire() diff --git a/src/filelock/_util.py b/src/filelock/_util.py index f6c52477..41067944 100644 --- a/src/filelock/_util.py +++ b/src/filelock/_util.py @@ -1,15 +1,22 @@ from __future__ import annotations -__lazy_modules__ = ["contextlib", "pathlib", "secrets", "typing"] +__lazy_modules__ = ["pathlib", "platform", "secrets"] +import errno import os +import platform import secrets import stat import sys -from contextlib import suppress -from errno import EACCES, EIO, EISDIR +from contextlib import contextmanager, suppress +from errno import EACCES, EINVAL, EIO, EISDIR, ENOSYS, EXDEV +from functools import cache from pathlib import Path -from typing import Final +from typing import TYPE_CHECKING, Final, cast + +if TYPE_CHECKING: + import ctypes + from collections.abc import Callable, Generator def write_all(fd: int, data: bytes) -> None: @@ -83,47 +90,229 @@ def ensure_directory_exists(filename: Path | str) -> None: Path(filename).parent.mkdir(parents=True, exist_ok=True) +@contextmanager +def pin_inode(lock_file: str) -> Generator[int | None]: + """ + Keep the file at *lock_file* allocated for the length of the block and yield its inode number. + + An inode is the kernel's identity for a file, independent of its name, and its number names a file only while the + inode stays allocated. ext4 hands a freed number to the next file created. Once a stale marker is unlinked, the next + holder's marker can therefore carry the number a contender read a moment ago. A caller that reads the marker inside + the block, gives up unless it read the yielded inode, and breaks the marker before leaving can rely on the number. + + :param lock_file: path to the lock file about to be read and perhaps broken. + + :returns: the pinned inode number, or ``None`` where nothing can be pinned. Windows cannot rename a file this + process holds open, so it needs no pin. Without ``O_PATH`` a symlink or a socket cannot be opened. + + :raises FileNotFoundError: if the path is gone. + + """ + if sys.platform == "win32": # pragma: win32 cover + yield None + else: # pragma: win32 no cover + # O_PATH pins any node, a symlink or socket included, without read permission and without the filesystem seeing + # an open; elsewhere a nonblocking read-only open pins what it can. + flags: Final = getattr(os, "O_PATH", os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(lock_file, flags) + except FileNotFoundError: + raise + except OSError: + yield None + return + try: + yield os.fstat(fd).st_ino + finally: + os.close(fd) + + +def remove_own_file(lock_file: str, fd: int) -> None: # pragma: win32 no cover + """ + Remove *lock_file* while it still names the file open as *fd*; the caller closes the descriptor afterwards. + + Where the platform has an atomic exchange, a stale break takes the file through it, so a peer's marker found at the + path goes back. Otherwise the path is unlinked by name if it still names this file. The open descriptor keeps the + inode allocated, so a matching number cannot belong to a successor's marker. + + :raises OSError: if the descriptor cannot be read or the unlink fails. + + """ + own: Final = os.fstat(fd) + with suppress(FileNotFoundError): + break_lock_file(lock_file, own.st_mtime, own.st_ino) + if os.path.samestat(os.lstat(lock_file), own): + Path(lock_file).unlink() + + def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) -> None: """ Remove the lock file a caller found stale with modification time *mtime_before* and inode *ino_before*. - Right before renaming we ``lstat`` the lock again and leave it alone if a peer recreated it since the caller judged - it stale, as Mercurial re-reads a lock under its break lock. We rename before unlinking so that, of several - processes racing to break the same lock, one takes the file and the rest get ``OSError``. A newer modification time - or another inode on the renamed file means a peer recreated the lock between that ``lstat`` and the rename, so we - hard-link it back and drop the break name. ``link`` never replaces a name, so a marker a third process created after - our rename wins and the live one stays under the break name. In the two syscalls between rename and link a third - process can acquire beside the live holder, a soft lock by creating its marker or a StrictSoftFileLock by - publishing its sentinel at the empty path. Without ``os.link``, or on a filesystem that refuses hard links, the live - marker stays under the break name. We compare inodes as well as modification times because NFS and FAT store - coarse modification times, and a peer that recreates the lock within that granularity leaves the old mtime on it. - We call ``lstat`` to avoid following a symlink a peer swaps in after our stale check, and put back only a regular - file because ``link`` would follow a symlink to its target. - - We add a random token to the break name so other processes cannot guess it and two breakers in one process do not - share ``.break.``. With a shared name, the second breaker could rename a recreated live lock onto that - path between our ``lstat`` and ``unlink``, and our ``unlink`` would delete it. + The caller keeps the judged file open until we return, through :func:`pin_inode` or its own descriptor, so no + other file can carry *ino_before* meanwhile. Right before breaking we ``lstat`` the lock again and leave it alone + if a peer recreated it since the caller judged it stale, as Mercurial re-reads a lock under its break lock. + + On Linux (``renameat2``) and macOS (``renamex_np``) the platform can exchange two names in one step. There we swap + the marker with a placeholder of our own, so the lock path never stands empty, and a contender that polls meanwhile + finds a file it cannot parse and waits. If the file we took is not the one judged stale, a peer recreated the lock, + and a second exchange puts it back. Otherwise we remove it and then our placeholder. Of several processes racing to + break the same lock, only one takes the stale file. + + Windows cannot exchange names, but it refuses to rename a marker its live holder keeps open. There we rename the + marker aside and, if it was recreated after all, link it back. ``link`` never replaces a name, so a marker a third + process created meanwhile keeps the path. Elsewhere without an exchange (NFS, SMB and other filesystems that refuse + it, platforms without the call) a rename would leave the path empty beside a possibly live holder. We then do not + move the marker, and the caller waits for ``timeout`` or ``break_lock()``. + + We compare inodes as well as modification times, because NFS and FAT store coarse modification times. The times are + compared for equality, since a file's owner can set any time. We ``lstat`` so a symlink a peer swaps in is never + followed. The break name carries a random token so no other process can guess it. :param lock_file: path to the lock file to break. :param mtime_before: modification time the caller saw when it judged the lock stale. :param ino_before: inode number the caller saw when it judged the lock stale. - :raises OSError: if the rename, the re-check or the restore fails, for example because the file vanished or another - user owns it in a sticky directory. + :raises OSError: if taking the marker fails, for example because the file vanished, another user owns it in a + sticky directory, or on Windows its holder keeps it open. """ - if (current := os.lstat(lock_file)).st_mtime > mtime_before or current.st_ino != ino_before: + if (current := os.lstat(lock_file)).st_mtime != mtime_before or current.st_ino != ino_before: return break_path: Final[str] = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}" + if exchange_supported(): # pragma: win32 no cover + _swap_out(lock_file, break_path, mtime_before, ino_before) + elif _MOVES_WITHOUT_EXCHANGE: # pragma: win32 cover + _break_by_rename(lock_file, break_path, mtime_before, ino_before) + + +def _swap_out(lock_file: str, break_path: str, mtime_before: float, ino_before: int) -> None: # pragma: win32 no cover + # An atomic exchange with a placeholder takes the marker without the lock path ever being empty. No contender can + # then create a marker while this one is judged, and a live marker goes back the same way. + fd: Final = os.open(break_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + try: + try: + # Not a record any lock writes, so a contender that reads it waits, and one left by a crash ages out. The + # PID tells the contender that evicts it whose break names to search for a marker this process left. + write_all(fd, f"filelock/break\n{os.getpid()}\n".encode()) + exchanged = exchange_names(break_path, lock_file) + except OSError: + exchanged = False + if not exchanged: + Path(break_path).unlink() + return + if (grabbed := os.lstat(break_path)).st_mtime == mtime_before and grabbed.st_ino == ino_before: + Path(break_path).unlink() + # Our descriptor keeps the placeholder's inode allocated, so a matching number at the path is still ours. + if os.path.samestat(os.lstat(lock_file), os.fstat(fd)): + Path(lock_file).unlink() + else: + exchange_names(break_path, lock_file) + Path(break_path).unlink() + finally: + os.close(fd) + + +def _break_by_rename( + lock_file: str, break_path: str, mtime_before: float, ino_before: int +) -> None: # pragma: win32 cover + # Renaming the marker aside empties the path until a recreated live marker is put back. link never replaces a name, + # so a marker a third process created meanwhile keeps the path, and the live one waits under the break name. + # Without os.link, or where the filesystem refuses hard links, the live one waits there too. Path(lock_file).rename(break_path) - if (st_after := os.lstat(break_path)).st_mtime <= mtime_before and st_after.st_ino == ino_before: + if (st_after := os.lstat(break_path)).st_mtime == mtime_before and st_after.st_ino == ino_before: Path(break_path).unlink() - elif stat.S_ISREG(st_after.st_mode) and _HAS_LINK: # pragma: needs hard-link - with suppress(FileExistsError): - os.link(break_path, lock_file) + elif stat.S_ISREG(st_after.st_mode) and hasattr(os, "link"): # pragma: needs hard-link + with suppress(OSError): + os.link(break_path, lock_file, follow_symlinks=False) Path(break_path).unlink() +def exchange_supported() -> bool: + """Whether this platform has a call that swaps two names in one step; a filesystem may still refuse it.""" + return _rename_call() is not None + + +def exchange_names(source: str, target: str) -> bool: # pragma: win32 no cover + """ + Swap the names *source* and *target* in one step, so neither ever stands empty, where :func:`exchange_supported`. + + :returns: ``False`` where the filesystem cannot, so the caller falls back + + :raises OSError: for any other failure, such as a missing name or a denied rename + + """ + renamer, exchange_flag = cast("tuple[Callable[[bytes, bytes, int], int], int]", _rename_call()) + if not (error := renamer(os.fsencode(source), os.fsencode(target), exchange_flag)): + return True + # EXDEV: overlayfs and some FUSE filesystems refuse an exchange between layers. + if error in {EINVAL, ENOSYS, EXDEV, getattr(errno, "ENOTSUP", EINVAL), getattr(errno, "EOPNOTSUPP", EINVAL)}: + return False + raise OSError(error, os.strerror(error), source, None, target) + + +@cache +def _rename_call() -> tuple[Callable[[bytes, bytes, int], int], int] | None: + # The call returns 0 or the errno. Looked up on first use, since most processes never break a lock and ctypes is + # slow to import. None where ctypes is missing (GraalPy) or the platform has no exchange. + try: + import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it + except ImportError: # pragma: no cover # GraalPy ships without ctypes + return None + if sys.platform in {"linux", "android"}: # pragma: linux cover + if (renameat2 := _renameat2(ctypes.CDLL(None, use_errno=True))) is None: + return None # pragma: no cover # an architecture the CI matrix does not run + return renameat2, 2 # RENAME_EXCHANGE in linux/fs.h + if sys.platform == "darwin": # pragma: darwin cover + renamex_np: Final = ctypes.CDLL(None, use_errno=True).renamex_np + renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + renamex_np.restype = ctypes.c_int + + def call_renamex_np(source: bytes, target: bytes, flags: int) -> int: + return 0 if renamex_np(source, target, flags) == 0 else ctypes.get_errno() + + return call_renamex_np, 0x2 # RENAME_SWAP in sys/stdio.h + return None # pragma: linux no cover # pragma: android no cover # pragma: darwin no cover + + +def _renameat2(libc: ctypes.CDLL) -> Callable[[bytes, bytes, int], int] | None: # pragma: linux cover + # Reached through syscall(2) rather than the C library's wrapper. glibc 2.28, musl 1.2.2 and bionic API 30 added + # the wrapper, and Termux ships an older bionic; the kernel has had the call since 3.15. The number is the + # architecture's own, so an architecture outside the table gets no exchange rather than another call. + import ctypes # ruff:ignore[import-outside-top-level] # only a stale break needs it + + numbers: Final = { + "x86_64": 316, + "i386": 353, + "i686": 353, + "armv7l": 382, + "armv8l": 382, + "aarch64": 276, + "riscv64": 276, + "loongarch64": 276, + "ppc64le": 357, + "s390x": 347, + } + if (number := numbers.get(platform.machine())) is None: + return None # pragma: no cover # an architecture the CI matrix does not run + syscall: Final = libc.syscall + syscall.restype = ctypes.c_long + at_fdcwd: Final = -100 + + def call_renameat2(source: bytes, target: bytes, flags: int) -> int: + arguments: Final = ( + ctypes.c_long(number), + ctypes.c_int(at_fdcwd), + ctypes.c_char_p(source), + ctypes.c_int(at_fdcwd), + ctypes.c_char_p(target), + ctypes.c_uint(flags), + ) + return 0 if syscall(*arguments) == 0 else ctypes.get_errno() + + return call_renameat2 + + def touch(name: str, *, fd: int) -> None: # Prefer the already-open, already-verified fd so a peer that swaps a symlink or a different file in at the # path after our O_NOFOLLOW read cannot redirect the touch: utime then targets the inode behind the fd. @@ -135,8 +324,10 @@ def touch(name: str, *, fd: int) -> None: os.utime(name, None, follow_symlinks=not _SUPPORTS_UTIME_NOFOLLOW) # pragma: lacks utime-fd -# Termux/Android CPython ships without os.link. -_HAS_LINK: Final[bool] = hasattr(os, "link") +# Windows refuses to rename a marker its live holder keeps open, so renaming aside only moves a marker nobody holds. +# Elsewhere without an exchange a contender could take the emptied path beside a live holder, so the marker stays and +# the lock counts as held. +_MOVES_WITHOUT_EXCHANGE: Final[bool] = sys.platform == "win32" # Retargeting os.utime to an open fd lets a heartbeat refresh the exact inode it verified instead of whatever the # pathname now names. _SUPPORTS_UTIME_FD: Final[bool] = sys.platform != "win32" and os.utime in os.supports_fd @@ -147,7 +338,11 @@ def touch(name: str, *, fd: int) -> None: __all__ = [ "break_lock_file", "ensure_directory_exists", + "exchange_names", + "exchange_supported", + "pin_inode", "raise_on_not_writable_file", + "remove_own_file", "touch", "write_all", ] diff --git a/tests/conftest.py b/tests/conftest.py index 4079ddd0..f772579f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,9 @@ import pytest from capabilities import CAPABILITIES -from filelock import BaseFileLock, Timeout +import filelock._identity +from filelock import BaseFileLock, MarkerSoftFileLock, SoftFileLease, SoftFileLock, Timeout +from filelock._identity import host_name if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator @@ -119,6 +121,96 @@ def close(fd: int) -> None: real_close(locked_fd) +@pytest.fixture(params=[pytest.param("native", id="native"), pytest.param("no-exchange", id="no-exchange")]) +def break_route(request: pytest.FixtureRequest, mocker: MockerFixture) -> None: + """Run the test with the platform's own stale break, then as where no atomic exchange of two names exists.""" + if request.param == "no-exchange": + mocker.patch("filelock._util._rename_call", return_value=None) + + +@pytest.fixture +def on_break_recheck(mocker: MockerFixture) -> Callable[[Path, Callable[[], None]], None]: + """Schedule an action for right after the stale break re-reads the lock file and before it takes the marker.""" + + def schedule(lock: Path, action: Callable[[], None]) -> None: + alive: Final = filelock._identity.process_alive + lstat: Final = os.lstat + state: list[str] = [] + + def judged(pid: int) -> bool: + # The owner check comes right before the break, whose first look at the path is the recheck. + state.append("judged") + return alive(pid) + + def lstat_then_act(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if state == ["judged"] and os.fspath(path) == os.fspath(lock): + state.append("acted") + action() + return found + + mocker.patch("filelock._identity.process_alive", side_effect=judged) + mocker.patch.object(os, "lstat", new=lstat_then_act) + + return schedule + + +@pytest.fixture +def reuse_inode(mocker: MockerFixture) -> Callable[[Path, os.stat_result], None]: # pragma: win32 no cover + """ + Stand in for ext4, which hands a freed inode number to the next file created. + + A tmpfs test directory never reuses a number. The stand-in makes ``os.lstat`` report a successor under the number of + the unlinked file it is given, once no descriptor in this process keeps that inode allocated. + """ + + def stand_in(successor: Path, freed: os.stat_result) -> None: + created: Final = successor.lstat() + lstat: Final = os.lstat + + def lstat_with_reuse(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if (found.st_dev, found.st_ino) != (created.st_dev, created.st_ino) or _held_open(freed): + return found + # The tuple carries the integer fields up to st_size; the float and nanosecond times ride in the mapping. + extra: Final = { + name: getattr(found, name) + for name in dir(found) + if name.startswith("st_") + and name not in {"st_mode", "st_ino", "st_dev", "st_nlink", "st_uid", "st_gid", "st_size"} + } + return os.stat_result((found.st_mode, freed.st_ino, *found[2:10]), extra) + + mocker.patch("os.lstat", side_effect=lstat_with_reuse) + + return stand_in + + +def _held_open(node: os.stat_result) -> bool: # pragma: win32 no cover + for entry in Path("/dev/fd").iterdir(): + with suppress(OSError): # the descriptor the listing itself used is closed by now + if os.path.samestat(os.fstat(int(entry.name)), node): + return True + return False + + +#: Linux and macOS cap PIDs below 2**22 + 1, and Windows answers OpenProcess for it with an invalid-parameter error, so +#: the marker names a holder every platform reads as dead. +DEAD_PID: Final[int] = 2**22 + 1 +DEAD_HOLDER: Final[str] = f"{DEAD_PID}\n{host_name()}\n" +DEAD_OWNER: Final[str] = f"filelock/2\npid={DEAD_PID}\nhost={host_name()}\nmode=exclusive\n" + +#: Every soft lock that breaks a stale marker, with a record naming a dead owner in the format it reads. +STALE_BREAKERS: Final[pytest.MarkDecorator] = pytest.mark.parametrize( + ("lock_type", "record"), + [ + pytest.param(SoftFileLock, DEAD_HOLDER, id="soft"), + pytest.param(MarkerSoftFileLock, DEAD_OWNER, id="marker"), + pytest.param(SoftFileLease, DEAD_OWNER, id="lease"), + ], +) + + @pytest.fixture def working_directories(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: first = tmp_path / "first" diff --git a/tests/process_helpers.py b/tests/process_helpers.py index 44f072dd..ba30ba1a 100644 --- a/tests/process_helpers.py +++ b/tests/process_helpers.py @@ -1,11 +1,17 @@ from __future__ import annotations +import time from contextlib import contextmanager from typing import TYPE_CHECKING, Final +from unittest.mock import patch + +import filelock._soft +from filelock import SoftFileLock if TYPE_CHECKING: from collections.abc import Generator from multiprocessing.process import BaseProcess + from multiprocessing.synchronize import Event #: Stays under the suite's per-test timeout so a wedged worker still surfaces as the hang it is. _REAP_DEADLINE: Final[int] = 5 @@ -29,4 +35,22 @@ def cleanup_processes(processes: list[BaseProcess]) -> Generator[None]: process.close() -__all__ = ["cleanup_processes"] +def break_then_hang_after_exchange( + lock_file: str, ready: Event, go: Event, exchanged: Event +) -> None: # pragma: win32 no cover + """Break a stale marker, pausing right before the exchange and hanging right after it, until the test kills it.""" + exchange: Final = filelock._soft.exchange_names + + def exchange_then_hang(source: str, target: str) -> bool: + ready.set() + go.wait() + exchange(source, target) + exchanged.set() + while True: + time.sleep(_REAP_DEADLINE) + + with patch.object(filelock._soft, "exchange_names", new=exchange_then_hang): + SoftFileLock(lock_file).acquire(blocking=False) + + +__all__ = ["break_then_hang_after_exchange", "cleanup_processes"] diff --git a/tests/test_lock_expiry.py b/tests/test_lock_expiry.py index b44fb6b1..6fb8f6b8 100644 --- a/tests/test_lock_expiry.py +++ b/tests/test_lock_expiry.py @@ -2,6 +2,7 @@ import os import socket +import sys import time from typing import TYPE_CHECKING, Final @@ -10,6 +11,7 @@ from filelock import AsyncFileLock, AsyncSoftFileLock, FileLock, SoftFileLock, Timeout if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture @@ -69,6 +71,8 @@ def test_soft_lifetime_none_no_expiry(tmp_path: Path) -> None: SoftFileLock(lock_path, lifetime=None, timeout=0.2).acquire() +@pytest.mark.usefixtures("break_route") +@pytest.mark.parametrize("break_route", ["no-exchange"], indirect=True) def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() @@ -80,6 +84,38 @@ def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) - SoftFileLock(lock_path, lifetime=0.1, timeout=0.5).acquire() +@pytest.mark.skipif(sys.platform == "win32", reason="Windows pins nothing") +def test_expired_break_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover + tmp_path: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None] +) -> None: + (lock := tmp_path / "test.lock").write_text("expired\n", encoding="utf-8") + os.utime(lock, (0, 0)) + expired: Final = lock.lstat() + lstat: Final = os.lstat + seen: list[bool] = [] + + def peer_breaks_then_successor_acquires( + path: str | os.PathLike[str], *, dir_fd: int | None = None + ) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if os.fspath(path) == os.fspath(lock) and not seen: + # After the age check: a peer broke the expired marker and the next holder's marker took its freed inode + # number, created with the same modification time, so only the pinned inode tells the two apart. + seen.append(True) + lock.unlink() + lock.write_text("live", encoding="utf-8") + os.utime(lock, ns=(expired.st_atime_ns, expired.st_mtime_ns)) + reuse_inode(lock, expired) + return found + + mocker.patch.object(os, "lstat", new=peer_breaks_then_successor_acquires) + with pytest.raises(Timeout): + SoftFileLock(lock, lifetime=0.1).acquire(blocking=False) + mocker.stopall() + + assert lock.read_text(encoding="utf-8") == "live" + + def test_lifetime_property_getter_setter(tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock", lifetime=10.0) assert lock.lifetime == pytest.approx(10.0) diff --git a/tests/test_soft_lease.py b/tests/test_soft_lease.py index 5dd77b85..fffd148c 100644 --- a/tests/test_soft_lease.py +++ b/tests/test_soft_lease.py @@ -28,6 +28,7 @@ ) from filelock._identity import host_name from filelock._soft import MALFORMED_LOCK_AGE_THRESHOLD +from filelock._util import exchange_names from tests.capability_marks import NEEDS_COLLECTED_FINALIZATION, NEEDS_PROMPT_FINALIZATION, NEEDS_UNLINK_OPEN_FILE from tests.conftest import held_after_reclaim @@ -260,6 +261,47 @@ def flaky(path: str, *args: object, **kwargs: object) -> object: assert lease.compromise is None +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_lease_heartbeat_tolerates_one_refresh_under_a_peers_exchange( # pragma: win32 no cover + marker: Path, mocker: MockerFixture +) -> None: + seen: list[LeaseCompromise] = [] + lease: Final = SoftFileLease(marker, lease_duration=2, heartbeat_interval=0.05, on_compromise=seen.append) + refreshed: Final = Event() + lstat: Final = os.lstat + + def spy(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + os.fspath(path) == str(marker) and refreshed.set() + return found + + with lease: + (peer := marker.with_name("peer")).write_text("peer", encoding="utf-8") + exchange_names(str(marker), str(peer)) + mocker.patch.object(os, "lstat", new=spy) + assert refreshed.wait(5) + exchange_names(str(marker), str(peer)) + mocker.stopall() + time.sleep(0.15) + + assert (lease.compromise, seen, lease.is_locked) == (None, [], True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_lease_heartbeat_reports_an_owner_changed_that_persists(marker: Path) -> None: # pragma: win32 no cover + seen: list[LeaseCompromise] = [] + lease: Final = SoftFileLease(marker, lease_duration=2, heartbeat_interval=0.05, on_compromise=seen.append) + + with lease: + (peer := marker.with_name("peer")).write_text("peer", encoding="utf-8") + exchange_names(str(marker), str(peer)) + _wait_until(lambda: bool(seen)) + time.sleep(0.2) + exchange_names(str(marker), str(peer)) + + assert [compromise.reason for compromise in seen] == ["owner-changed"] + + @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_one_compromise_per_claim(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] @@ -907,3 +949,25 @@ def _wait_until(condition: Callable[[], bool]) -> None: def _lease_heartbeats() -> set[threading.Thread]: return {thread for thread in threading.enumerate() if thread.name.startswith("filelock-lease-")} + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_lease_takes_over_a_dead_owners_marker_with_its_own_lease_record( # pragma: win32 no cover + tmp_path: Path, +) -> None: + (lock := tmp_path / "a.lock").write_text( + f"filelock/2\npid={2**22 + 1}\nhost={host_name()}\nmode=exclusive\n", encoding="utf-8" + ) + lease: Final = SoftFileLease(lock, lease_duration=5) + lease.acquire(blocking=False) + try: + owner: Final = lease.owner + assert owner is not None + assert (owner.mode, owner.token, owner.lease_duration, lease.is_lock_held_by_us) == ( + "lease", + lease.token, + 5.0, + True, + ) + finally: + lease.release() diff --git a/tests/test_soft_stale.py b/tests/test_soft_stale.py index b0fa0f14..03d2c8d5 100644 --- a/tests/test_soft_stale.py +++ b/tests/test_soft_stale.py @@ -6,23 +6,27 @@ import socket import sys import threading -from contextlib import contextmanager -from errno import EACCES, EINTR, ENODEV, ENOSPC, EPERM +from contextlib import contextmanager, suppress +from errno import EACCES, EINTR, EINVAL, EIO, ENODEV, ENOSPC, EPERM +from multiprocessing import get_context from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from capabilities import CAPABILITIES -from filelock import CloseErrorPolicy, MarkerSoftFileLock, SoftFileLock, Timeout +from filelock import CloseErrorPolicy, MarkerSoftFileLock, SoftFileLease, SoftFileLock, Timeout from filelock._identity import host_name, process_start_token -from filelock._soft import _MAX_LOCK_FILE_SIZE, MALFORMED_LOCK_AGE_THRESHOLD +from filelock._soft import _MAX_LOCK_FILE_SIZE, MALFORMED_LOCK_AGE_THRESHOLD, _begin_claim, _end_claim, _read_lock_file +from filelock._soft_protocol import STRICT_SOFT_SENTINEL_RECORD +from filelock._util import exchange_names from tests.capability_marks import NEEDS_POSIX_SIGNALS, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE -from tests.conftest import acquire_after_reclaim, held_after_reclaim +from tests.conftest import DEAD_OWNER, STALE_BREAKERS, acquire_after_reclaim, held_after_reclaim from tests.pid_namespace_helpers import pin_pid_namespace +from tests.process_helpers import break_then_hang_after_exchange, cleanup_processes if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator from unittest.mock import MagicMock from pytest_mock import MockerFixture @@ -33,6 +37,8 @@ _DEAD_PID: Final[int] = 2**22 + 1 _WIN_ERROR_ACCESS_DENIED: Final[int] = 5 _WIN_ERROR_INVALID_PARAMETER: Final[int] = 87 +#: A spawned GraalPy interpreter alone can take seconds to start on a loaded runner. +_PROCESS_DEADLINE: Final[int] = 30 @pytest.fixture @@ -194,6 +200,24 @@ def test_unparseable_lock_not_evicted_when_fresh(lock_path: Path, content: bytes _assert_times_out(lock_path) +@pytest.mark.skipif(sys.platform == "win32", reason="st_ctime is the creation time on Windows, which no change moves") +@pytest.mark.usefixtures("unchanged_past_grace") +def test_unparseable_lock_changed_under_its_old_mtime_and_inode_not_evicted( # pragma: win32 no cover + lock_path: Path, +) -> None: + # Another file can repeat both: its owner sets the mtime, and ext4 hands a freed inode number to the next file. + lock_path.write_bytes(b"") + watched: Final = lock_path.stat() + contender: Final = SoftFileLock(lock_path) + with pytest.raises(Timeout): + contender.acquire(blocking=False) + while lock_path.stat().st_ctime_ns == watched.st_ctime_ns: + os.utime(lock_path, ns=(watched.st_atime_ns, watched.st_mtime_ns)) + with pytest.raises(Timeout): + contender.acquire(blocking=False) + assert lock_path.exists() + + @_REQUIRES_START_TOKEN def test_fresh_marker_with_truncated_start_token_not_evicted(lock_path: Path) -> None: # A reader racing our own write sees a cut start token, which must not pass for a recycled PID and break our lock. @@ -225,6 +249,8 @@ def test_out_of_range_pid_not_evicted_when_fresh(lock_path: Path) -> None: _assert_times_out(lock_path) +@pytest.mark.usefixtures("break_route") +@pytest.mark.parametrize("break_route", ["no-exchange"], indirect=True) def test_stale_lock_rename_race(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=False) @@ -232,20 +258,17 @@ def test_stale_lock_rename_race(lock_path: Path, mocker: MockerFixture) -> None: _assert_times_out(lock_path) -@pytest.mark.requires_hard_links -def test_stale_break_racing_a_successor_admits_no_third_holder( # pragma: needs hard-link - lock_path: Path, mocker: MockerFixture +def test_stale_break_racing_a_successor_admits_no_third_holder( + lock_path: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") successor: Final[SoftFileLock] = SoftFileLock(lock_path, timeout=0) - rename: Final = Path.rename - def successor_breaks_and_acquires_first(source: Path, target: str) -> Path: - source.unlink() + def successor_breaks_and_acquires_first() -> None: + lock_path.unlink() successor.acquire() - return rename(source, target) - mocker.patch.object(Path, "rename", autospec=True, side_effect=successor_breaks_and_acquires_first) + on_break_recheck(lock_path, successor_breaks_and_acquires_first) _assert_times_out(lock_path) mocker.stopall() @@ -253,6 +276,594 @@ def successor_breaks_and_acquires_first(source: Path, target: str) -> Path: successor.release() +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_stale_break_keeps_the_lock_when_a_peer_swaps_its_marker_aside( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture +) -> None: + lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") + (peer := lock_path.with_name("peer")).write_text(_holder(os.getpid()), encoding="utf-8") + unlink: Final = Path.unlink + + def peer_exchanges_then_unlink(path: Path, *, missing_ok: bool = False) -> None: + exchange_names(str(lock_path), str(peer)) + unlink(path, missing_ok=missing_ok) + + mocker.patch.object(Path, "unlink", new=peer_exchanges_then_unlink) + lock: Final = SoftFileLock(lock_path) + lock.acquire(blocking=False) + mocker.stopall() + exchange_names(str(lock_path), str(peer)) + + assert (lock.is_locked, lock.is_lock_held_by_us) == (True, True) + lock.release() + assert list(lock_path.parent.glob("test.lock*")) == [] + + +def _break_name(lock: Path, pid: int = _DEAD_PID) -> Path: # pragma: win32 no cover + return lock.with_name(f"test.lock.break.{pid}.{'0' * 32}") + + +def _break_contents(lock: Path) -> list[str]: # pragma: win32 no cover + return [path.read_text(encoding="utf-8") for path in lock.parent.glob(f"{lock.name}.break.*")] + + +def _live(record: str) -> str: # pragma: win32 no cover + return record.replace(str(_DEAD_PID), str(os.getpid())) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_restores_a_live_marker_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + _break_name(lock).write_text(_live(record), encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (_live(record), []) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_restores_a_marker_of_another_host_with_the_breakers_pid( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == (foreign, []) + + +def _restore_name(lock: Path, pid: int) -> Path: # pragma: win32 no cover + return lock.with_name(f"test.lock.restore.{pid}.{'0' * 32}") + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_restores_under_an_exclusive_claim( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + first_has_checked: Final = threading.Event() + release_rival: Final = threading.Event() + rival_timeouts: Final[list[bool]] = [] + + def rival_recovers() -> None: + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + rival_timeouts.append(True) + + rival: Final = threading.Thread(target=rival_recovers) + main: Final = threading.current_thread() + exchange: Final = exchange_names + unlink: Final = Path.unlink + + def settle(until: threading.Event | None = None) -> None: + while rival.is_alive() and not (until and until.is_set()): + rival.join(0.001) + + def exchange_with_a_rival_between_check_and_exchange(first: str, second: str) -> bool: + # The rival reaches its own exchange only when recovery lets two contenders act on one record. + if threading.current_thread() is main: + rival.start() + settle(first_has_checked) + return exchange(first, second) + first_has_checked.set() # pragma: no cover + while not release_rival.is_set(): # pragma: no cover + release_rival.wait(0.001) # pragma: no cover + return exchange(first, second) # pragma: no cover + + def unlink_after_the_rival_finished(path: Path, *, missing_ok: bool = False) -> None: + release_rival.set() + settle() + unlink(path, missing_ok=missing_ok) + + mocker.patch("filelock._soft.exchange_names", new=exchange_with_a_rival_between_check_and_exchange) + mocker.patch.object(Path, "unlink", new=unlink_after_the_rival_finished) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + release_rival.set() + settle() + + assert ( + rival_timeouts, + lock.read_text(encoding="utf-8"), + list(tmp_path.glob("test.lock.break.*")), + list(tmp_path.glob("test.lock.restore.*")), + ) == ([True], foreign, [], []) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_waits_when_a_peer_claimed_the_record_first( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + rename: Final = os.rename + + def peer_claims_first(source: Path, target: str) -> None: + rename(source, _restore_name(lock, _DEAD_PID - 1)) + rename(source, target) + + mocker.patch("filelock._soft.os.rename", side_effect=peer_claims_first) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), _restore_name(lock, _DEAD_PID - 1).read_text(encoding="utf-8")) == ( + record, + foreign, + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_waits_when_the_record_cannot_be_claimed( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + mocker.patch("filelock._soft.os.rename", side_effect=OSError(EACCES, "denied")) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_reclaims_a_record_a_dead_recoverer_claimed( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _restore_name(lock, _DEAD_PID).write_text(foreign, encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.restore.*"))) == (foreign, []) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_skips_a_record_a_live_recoverer_claimed( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + (claimed := _restore_name(lock, os.getppid())).write_text(foreign, encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (lock.read_text(encoding="utf-8"), claimed.read_text(encoding="utf-8")) == (record, foreign) + + +def _refuse_returning_a_claim(mocker: MockerFixture) -> None: # pragma: win32 no cover + rename: Final = Path.rename + + def rename_unless_returning(path: Path, target: str | Path) -> Path: + if ".break." in str(target): + raise OSError(EACCES, "denied") + return rename(path, target) + + mocker.patch.object(Path, "rename", new=rename_unless_returning) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_retries_a_claim_whose_return_failed( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + _refuse_returning_a_claim(mocker) + mocker.patch("filelock._soft.exchange_names", return_value=False) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + left: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) + mocker.stopall() + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (len(left), lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (1, foreign, [lock]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_finishes_a_claim_an_unreadable_record_interrupted( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + read: Final = _read_lock_file + + def read_unless_claimed(path: str) -> tuple[str | None, os.stat_result]: + if ".restore." in path: + raise OSError(EIO, "unreadable") + return read(path) + + _refuse_returning_a_claim(mocker) + mocker.patch("filelock._soft._read_lock_file", new=read_unless_claimed) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + left: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) + mocker.stopall() + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (len(left), lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (1, foreign, [lock]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_waits_for_a_claim_another_thread_is_working( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + _break_name(lock).write_text(record.replace(_HOST, "elsewhere"), encoding="utf-8") + working, release = threading.Event(), threading.Event() + exchange: Final = exchange_names + + def exchange_when_released(first: str, second: str) -> bool: + working.set() + release.wait(5) + return exchange(first, second) + + mocker.patch("filelock._soft.exchange_names", new=exchange_when_released) + worker: Final = threading.Thread(target=_acquire, args=(lock_type, lock)) + worker.start() + assert working.wait(5) + claims_while_working: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + untouched: Final = list(tmp_path.glob(f"test.lock.restore.{os.getpid()}.*")) + release.set() + worker.join() + + assert (len(claims_while_working), untouched == claims_while_working) == (1, True) + + +def _acquire_timing_out( # pragma: win32 no cover + lock_type: type[SoftFileLock], lock: Path, outcomes: list[bool] +) -> None: + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + outcomes.append(True) + + +def _acquire(lock_type: type[SoftFileLock], lock: Path) -> None: # pragma: win32 no cover + with suppress(Timeout): + lock_type(lock).acquire(blocking=False) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_claim_is_registered_before_it_is_visible( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + rename: Final = Path.rename + rival_timeouts: Final[list[bool]] = [] + claims_after_rival: Final[list[list[Path]]] = [] + + def rival_scans_after_the_claim_rename(path: Path, target: str | Path) -> Path: + renamed: Final = rename(path, target) + mocker.stopall() + rival: Final = threading.Thread(target=_acquire_timing_out, args=(lock_type, lock, rival_timeouts)) + rival.start() + rival.join() + claims_after_rival.append(list(tmp_path.glob("test.lock.restore.*"))) + return renamed + + mocker.patch.object(Path, "rename", new=rival_scans_after_the_claim_rename) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert ( + rival_timeouts, + len(claims_after_rival[0]), + lock.read_text(encoding="utf-8"), + sorted(tmp_path.iterdir()), + ) == ( + [True], + 1, + foreign, + [lock], + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_stops_when_the_claim_is_already_registered( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + mocker.patch("filelock._soft.secrets.token_hex", return_value="a" * 32) + registered: Final = str(lock) + f".restore.{os.getpid()}." + "a" * 32 + assert _begin_claim(registered) + try: + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + finally: + _end_claim(registered) + + assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_claim_identity_ignores_path_spelling( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + mocker.patch("filelock._soft.secrets.token_hex", return_value="a" * 32) + registered: Final = Path(f"{lock}.restore.{os.getpid()}.{'a' * 32}") + assert _begin_claim(registered) + monkeypatch.chdir(tmp_path) + try: + with pytest.raises(Timeout): + lock_type("./test.lock").acquire(blocking=False) + finally: + _end_claim(registered) + + assert (lock.read_text(encoding="utf-8"), _break_contents(lock)) == (record, [foreign]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.parametrize("interrupted", ["rename", "read", "exchange"]) +@STALE_BREAKERS +def test_stale_break_releases_the_registration_when_recovery_is_interrupted( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, interrupted: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + rename: Final = Path.rename + + def rename_then_interrupt(path: Path, target: str | Path) -> Path: + rename(path, target) + raise KeyboardInterrupt + + def read_the_claim_then_interrupt(path: str) -> tuple[str | None, os.stat_result]: + if ".restore." in path: + raise KeyboardInterrupt + return _read_lock_file(path) + + def exchange_then_interrupt(*_names: str) -> bool: + raise KeyboardInterrupt + + target, interruption = { + "rename": ("pathlib.Path.rename", rename_then_interrupt), + "read": ("filelock._soft._read_lock_file", read_the_claim_then_interrupt), + "exchange": ("filelock._soft.exchange_names", exchange_then_interrupt), + }[interrupted] + mocker.patch(target, new=interruption) + with pytest.raises(KeyboardInterrupt): + lock_type(lock).acquire(blocking=False) + mocker.stopall() + (claim,) = tmp_path.glob("test.lock.restore.*") + released: Final = _begin_claim(claim) + _end_claim(claim) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert (released, lock.read_text(encoding="utf-8"), sorted(tmp_path.iterdir())) == (True, foreign, [lock]) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_undoes_an_exchange_that_took_a_peers_marker( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str, mocker: MockerFixture +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + foreign: Final = record.replace(_HOST, "elsewhere") + _break_name(lock).write_text(foreign, encoding="utf-8") + (peer := tmp_path / "peer").write_text(_live(record), encoding="utf-8") + exchange: Final = exchange_names + + def peer_takes_the_path_then_exchange(first: str, second: str) -> bool: + mocker.patch("filelock._soft.exchange_names", new=exchange) + peer.replace(lock) + return exchange(first, second) + + mocker.patch("filelock._soft.exchange_names", new=peer_takes_the_path_then_exchange) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert ( + lock.read_text(encoding="utf-8"), + _break_contents(lock), + list(tmp_path.glob("test.lock.restore.*")), + ) == (_live(record), [foreign], []) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_removes_a_stale_marker_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + _break_name(lock).write_text(record.replace(str(_DEAD_PID), str(_DEAD_PID - 1)), encoding="utf-8") + contender: Final = lock_type(lock) + contender.acquire(blocking=False) + + assert (contender.is_locked, list(tmp_path.glob("test.lock.break.*"))) == (True, []) + contender.release() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_restores_a_strict_sentinel_a_dead_breaker_left_under_its_break_name( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + _break_name(lock).write_text(STRICT_SOFT_SENTINEL_RECORD, encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert lock.read_text(encoding="utf-8") == STRICT_SOFT_SENTINEL_RECORD + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.parametrize("lock_type", [MarkerSoftFileLock, SoftFileLease]) +def test_stale_break_restores_a_marker_of_an_unknown_contract_under_its_break_name( # pragma: win32 no cover + tmp_path: Path, lock_type: type[MarkerSoftFileLock] +) -> None: + (lock := tmp_path / "test.lock").write_text(DEAD_OWNER, encoding="utf-8") + future: Final = DEAD_OWNER.replace(str(_DEAD_PID), str(_DEAD_PID - 1)).replace("exclusive", "future") + _break_name(lock).write_text(future, encoding="utf-8") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + + assert lock.read_text(encoding="utf-8") == future + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.usefixtures("unchanged_past_grace") +@STALE_BREAKERS +def test_stale_break_restores_a_marker_a_dead_breaker_left_beside_its_placeholder( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(f"filelock/break\n{_DEAD_PID}\n", encoding="utf-8") + _break_name(lock).write_text(_live(record), encoding="utf-8") + contender: Final = lock_type(lock) + for _ in range(2): + with suppress(Timeout): + contender.acquire(blocking=False) + + assert (contender.is_locked, lock.read_text(encoding="utf-8"), list(tmp_path.glob("test.lock.break.*"))) == ( + False, + _live(record), + [], + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.usefixtures("unchanged_past_grace") +@pytest.mark.parametrize( + "placeholder", + [ + pytest.param("filelock/break\n", id="no-pid"), + pytest.param("filelock/break\nnot-a-pid\n", id="not-a-pid"), + pytest.param(f"filelock/break\n{_DEAD_PID}", id="torn-pid"), + ], +) +def test_stale_break_evicts_a_placeholder_that_names_no_breaker( # pragma: win32 no cover + tmp_path: Path, placeholder: str +) -> None: + (lock := tmp_path / "test.lock").write_text(placeholder, encoding="utf-8") + _break_name(lock).write_text(_holder(os.getpid()), encoding="utf-8") + contender: Final = SoftFileLock(lock) + for _ in range(2): + with suppress(Timeout): + contender.acquire(blocking=False) + + assert (contender.is_locked, _break_name(lock).exists()) == (True, True) + contender.release() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.parametrize("error", [pytest.param(EACCES, id="denied"), pytest.param(EINVAL, id="unsupported")]) +def test_stale_break_leaves_a_break_name_it_cannot_exchange( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture, error: int +) -> None: + lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") + _break_name(lock_path).write_text(_holder(os.getpid()), encoding="utf-8") + mocker.patch("filelock._util._rename_call", return_value=(mocker.MagicMock(return_value=error), 2)) + with pytest.raises(Timeout): + SoftFileLock(lock_path).acquire(blocking=False) + + assert (lock_path.read_text(encoding="utf-8"), _break_contents(lock_path)) == ( + _holder(_DEAD_PID), + [_holder(os.getpid())], + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_stale_break_waits_when_a_break_name_cannot_be_read( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture +) -> None: + lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") + _break_name(lock_path).write_text(_holder(os.getpid()), encoding="utf-8") + open_: Final = os.open + + def refuse_the_break_name(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: + if ".restore." in os.fspath(path): + raise OSError(EACCES, "Permission denied", path) + return open_(path, flags, mode, dir_fd=dir_fd) + + mocker.patch.object(os, "open", new=refuse_the_break_name) + with pytest.raises(Timeout): + SoftFileLock(lock_path).acquire(blocking=False) + mocker.stopall() + + assert (lock_path.read_text(encoding="utf-8"), _break_contents(lock_path)) == ( + _holder(_DEAD_PID), + [_holder(os.getpid())], + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.timeout(_PROCESS_DEADLINE * 2) +def test_stale_break_after_a_breaker_was_killed_keeps_the_holder_alone( # pragma: win32 no cover + lock_path: Path, +) -> None: + lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") + context: Final = get_context("spawn") + ready, go, exchanged = context.Event(), context.Event(), context.Event() + breaker: Final = context.Process(target=break_then_hang_after_exchange, args=(str(lock_path), ready, go, exchanged)) + holder: Final = SoftFileLock(lock_path) + with cleanup_processes([breaker]): + breaker.start() + assert ready.wait(_PROCESS_DEADLINE) + holder.acquire(blocking=False) + go.set() + assert exchanged.wait(_PROCESS_DEADLINE) + breaker.terminate() + breaker.join(_PROCESS_DEADLINE) + with pytest.raises(Timeout): + SoftFileLock(lock_path).acquire(blocking=False) + + assert (holder.is_locked, holder.is_lock_held_by_us) == (True, True) + holder.release() + + @NEEDS_SYMLINK def test_symlinked_lock_file_is_not_followed(tmp_path: Path, lock_path: Path) -> None: # pragma: needs symlink target = tmp_path / "target" @@ -520,6 +1131,51 @@ def test_zero_write_rolls_back_acquire(lock_path: Path, mocker: MockerFixture) - assert not lock_path.exists() +@pytest.mark.skipif(sys.platform == "win32", reason="Windows unlinks a marker only after closing it") +def test_failed_acquire_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture, reuse_inode: Callable[[Path, os.stat_result], None] +) -> None: + def evicted_and_succeeded_then_write_fails(fd: int, data: bytes) -> int: + # A contender evicted the still-empty marker, and the next holder's marker gets its inode number once freed. + ours: Final = os.fstat(fd) + lock_path.unlink() + lock_path.write_text("live", encoding="utf-8") + os.utime(lock_path, ns=(ours.st_atime_ns, ours.st_mtime_ns)) + reuse_inode(lock_path, ours) + raise OSError(ENOSPC, f"No space left on device for {len(data)} bytes") + + mocker.patch("filelock._util.os.write", side_effect=evicted_and_succeeded_then_write_fails) + with pytest.raises(OSError, match="No space left on device"): + SoftFileLock(lock_path).acquire() + mocker.stopall() + + assert lock_path.read_text(encoding="utf-8") == "live" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_failed_acquire_spares_a_file_that_replaced_its_placeholder( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture +) -> None: + mocker.patch("filelock._soft.os.fchmod", side_effect=OSError(EIO, "Input/output error")) + lstat: Final = os.lstat + replaced: list[bool] = [] + + def peer_replaces_the_placeholder(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if ".break." in os.fspath(path) and not replaced: + replaced.append(True) + (other := lock_path.with_name("other")).write_text("other", encoding="utf-8") + other.replace(lock_path) + return found + + mocker.patch.object(os, "lstat", new=peer_replaces_the_placeholder) + with pytest.raises(OSError, match="Input/output error"): + SoftFileLock(lock_path, mode=0o600).acquire() + mocker.stopall() + + assert (lock_path.read_text(encoding="utf-8"), list(lock_path.parent.glob("test.lock.break.*"))) == ("other", []) + + def test_failed_acquire_cleanup_spares_a_replacement(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", side_effect=OSError(ENOSPC, "No space left on device")) _hold_reports_foreign_identity(mocker) @@ -539,6 +1195,40 @@ def test_publication_without_identity_leaves_the_marker(lock_path: Path, mocker: assert (lock.is_locked, lock_path.exists()) == (False, True) +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.usefixtures("unchanged_past_grace") +def test_release_under_a_peers_exchange_leaves_no_marker_naming_this_process( # pragma: win32 no cover + lock_path: Path, +) -> None: + lock: Final = SoftFileLock(lock_path) + lock.acquire() + (peer := lock_path.with_name("peer")).write_text(_holder(_DEAD_PID), encoding="utf-8") + exchange_names(str(lock_path), str(peer)) + lock.release() + exchange_names(str(lock_path), str(peer)) + restored: Final = lock_path.read_text(encoding="utf-8") + + contender: Final = SoftFileLock(lock_path) + for _ in range(2): + with suppress(Timeout): + contender.acquire(blocking=False) + assert (restored.startswith(f"{os.getpid()}\n"), contender.is_locked) == (False, True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_release_under_a_peers_exchange_leaves_the_peers_marker(lock_path: Path) -> None: # pragma: win32 no cover + lock: Final = SoftFileLock(lock_path) + lock.acquire() + (peer := lock_path.with_name("peer")).write_text(_holder(_DEAD_PID), encoding="utf-8") + exchange_names(str(lock_path), str(peer)) + lock.release() + + assert (lock_path.read_text(encoding="utf-8"), peer.read_text(encoding="utf-8")) == ( + _holder(_DEAD_PID), + "filelock/released\n", + ) + + def test_release_without_identity_skips_unlink(lock_path: Path, mocker: MockerFixture) -> None: lock = SoftFileLock(lock_path) lock.acquire() @@ -692,6 +1382,20 @@ def test_close_error_spares_successor_marker(lock_path: Path, mocker: MockerFixt assert lock_path.read_text(encoding="utf-8") == _holder(_DEAD_PID) +@NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file +def test_released_record_write_error_spares_successor_marker(lock_path: Path, mocker: MockerFixture) -> None: + holder: Final = SoftFileLock(lock_path) + holder.acquire() + lock_path.unlink() + lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") + mocker.patch("filelock._soft.os.pwrite", side_effect=PermissionError(EACCES, "record denied")) + + holder.release() + + assert not holder.is_locked + assert lock_path.read_text(encoding="utf-8") == _holder(_DEAD_PID) + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows closes the descriptor before it can unlink the marker") def test_marker_cleanup_failure_keeps_the_lock_held_for_a_retry( # pragma: win32 no cover lock_path: Path, mocker: MockerFixture @@ -731,6 +1435,25 @@ def refuse_while_open(path: Path, missing_ok: bool = False) -> None: assert (lock.is_locked, lock_path.exists(), len(attempts)) == (False, False, 2) +@pytest.mark.skipif(sys.platform == "win32", reason="Windows always unlinks the marker after the close") +def test_marker_unlink_retried_after_the_close_spares_a_successor( # pragma: win32 no cover + lock_path: Path, mocker: MockerFixture +) -> None: + lock: Final = SoftFileLock(lock_path) + lock.acquire() + + def refuse_while_open_and_successor_replaces(path: Path, missing_ok: bool = False) -> None: + (successor := path.with_name("successor")).write_text("live", encoding="utf-8") + successor.replace(path) + raise PermissionError(EACCES, f"file in use, missing_ok={missing_ok}") + + mocker.patch.object(Path, "unlink", autospec=True, side_effect=refuse_while_open_and_successor_replaces) + lock.release() + mocker.stopall() + + assert (lock.is_locked, lock_path.read_text(encoding="utf-8")) == (False, "live") + + @pytest.mark.skipif(sys.platform != "win32", reason="POSIX unlinks the marker before the close, so both cannot fail") def test_close_and_marker_cleanup_failures_are_grouped( # pragma: win32 cover lock_path: Path, mocker: MockerFixture diff --git a/tests/test_unix_fallback.py b/tests/test_unix_fallback.py index 5d813e43..4c31e98c 100644 --- a/tests/test_unix_fallback.py +++ b/tests/test_unix_fallback.py @@ -166,6 +166,40 @@ def test_fallback_keeps_peer_soft_marker(tmp_path: Path) -> None: # pragma: nee holder.release() +@NEEDS_FCNTL +@pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") +def test_fallback_spares_a_peer_marker_that_replaced_its_placeholder( # pragma: needs fcntl + tmp_path: Path, mocker: MockerFixture +) -> None: + lock_path: Final = tmp_path / "test.lock" + peer_marker: Final = f"{os.getpid()}\n{host_name()}\n" + lstat: Final = os.lstat + stalled: list[bool] = [] + + def flock_missing(fd: int, operation: int) -> None: + stalled.append(False) + raise OSError(ENOSYS, f"Function not implemented for {fd} with {operation}") + + def lstat_then_peer_replaces_placeholder(path: str, *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if stalled == [False] and os.fspath(path) == os.fspath(lock_path): + # The cleanup stalls past the grace a waiting soft lock gives an empty file, which evicts the placeholder + # and publishes its own marker. + stalled[0] = True + lock_path.unlink() + lock_path.write_text(peer_marker, encoding="utf-8") + return found + + mocker.patch("filelock._unix.fcntl.flock", side_effect=flock_missing) + mocker.patch("os.lstat", side_effect=lstat_then_peer_replaces_placeholder) + with pytest.raises(Timeout): + UnixFileLock(lock_path).acquire(timeout=0) + mocker.stopall() + + # Where os.link cannot restore it, the peer's marker waits under a break name, but it is never deleted. + assert peer_marker in [path.read_text(encoding="utf-8") for path in tmp_path.glob("test.lock*")] + + @NEEDS_FCNTL # pragma: needs fcntl def test_trailing_slash_path_raises_instead_of_retrying(tmp_path: Path) -> None: # macOS reports ENOENT for a create through a trailing slash, Linux EISDIR; neither is contention. diff --git a/tests/test_util.py b/tests/test_util.py index b2696a46..29e3ca03 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,15 +5,17 @@ import sys import time from contextlib import suppress -from errno import EACCES, EIO, ENOENT, EPERM +from errno import EACCES, EINVAL, EIO, ELOOP, ENOENT, EPERM, ESRCH from pathlib import Path from typing import TYPE_CHECKING, Final import pytest +import filelock._util from filelock import SoftFileLock, Timeout from filelock._identity import host_name from tests.capability_marks import NEEDS_FILE_PERMISSIONS, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE +from tests.conftest import DEAD_HOLDER, STALE_BREAKERS if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -24,15 +26,12 @@ sys.platform != "win32" and os.geteuid() == 0, reason="root can write a 0o444 file", ) -# Linux and macOS cap PIDs below 2**22 + 1, and Windows answers OpenProcess for it with an invalid-parameter error, so -# the marker names a holder every platform reads as dead. -_DEAD_HOLDER: Final[str] = f"{2**22 + 1}\n{host_name()}\n" @pytest.fixture def stale_lock(tmp_path: Path) -> Path: path: Final[Path] = tmp_path / "test.lock" - path.write_text(_DEAD_HOLDER, encoding="utf-8") + path.write_text(DEAD_HOLDER, encoding="utf-8") return path @@ -41,12 +40,12 @@ def test_break_lock_file_unlinks_a_dead_holders_marker(stale_lock: Path) -> None assert list(stale_lock.parent.glob("test.lock.break.*")) == [] -def _rewrite_in_place(lock: Path) -> None: # pragma: needs hard-link +def _rewrite_in_place(lock: Path) -> None: lock.write_text("live", encoding="utf-8") os.utime(lock, (time.time() + 10, time.time() + 10)) -def _replace_with_same_mtime(lock: Path) -> None: # pragma: needs hard-link +def _replace_with_same_mtime(lock: Path) -> None: # A filesystem with coarse modification times (NFS, FAT) gives a same-second recreation the old mtime, so only the # inode tells it apart. Writing the replacement while the original exists guarantees a fresh inode. stale: Final[os.stat_result] = lock.stat() @@ -55,23 +54,21 @@ def _replace_with_same_mtime(lock: Path) -> None: # pragma: needs hard-link replacement.replace(lock) -@pytest.mark.requires_hard_links +@pytest.mark.usefixtures("break_route") @pytest.mark.parametrize( "recreate", [pytest.param(_rewrite_in_place, id="mtime-advanced"), pytest.param(_replace_with_same_mtime, id="inode-changed")], ) -def test_break_lock_file_restores_a_recreated_marker( # pragma: needs hard-link - stale_lock: Path, mocker: MockerFixture, recreate: Callable[[Path], None] +def test_break_lock_file_keeps_a_marker_recreated_after_the_recheck( + stale_lock: Path, + mocker: MockerFixture, + on_break_recheck: Callable[[Path, Callable[[], None]], None], + recreate: Callable[[Path], None], ) -> None: - rename: Final = Path.rename - - def peer_recreates_then_rename(source: Path, target: str) -> Path: - recreate(source) - return rename(source, target) - - mocker.patch.object(Path, "rename", autospec=True, side_effect=peer_recreates_then_rename) + on_break_recheck(stale_lock, lambda: recreate(stale_lock)) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() assert (stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*"))) == ("live", []) @@ -101,8 +98,53 @@ def read_then_peer_recreates(fd: int, length: int) -> bytes: assert (stale_lock.read_text(encoding="utf-8"), rename.call_count) == ("live", 0) -@pytest.mark.requires_hard_links -def test_break_lock_file_keeps_a_third_marker_created_after_the_rename( # pragma: needs hard-link +@pytest.mark.usefixtures("break_route") +@pytest.mark.parametrize( + "point", + [ + pytest.param("lstat", id="after-the-recheck-and-the-take"), + pytest.param("open", id="after-the-placeholder"), + pytest.param("unlink", id="after-each-removal"), + ], +) +def test_stale_break_admits_no_third_holder_beside_a_recreated_marker( + stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None], point: str +) -> None: + # A peer broke the stale marker and the next holder acquired after the recheck. A third contender then polls at + # every step of the break, including between taking the marker and checking whose it is. + holder: Final = SoftFileLock(stale_lock) + overlaps: list[bool] = [] + polling: list[bool] = [] + real: Final = getattr(os, point) + + def third_contender_polls(path: str | os.PathLike[str], *args: int, **kwargs: int | None) -> object: + result: Final = real(path, *args, **kwargs) + if holder.is_locked and not polling and os.fspath(path).startswith(os.fspath(stale_lock)): + polling.append(True) + third: Final = SoftFileLock(stale_lock) + with suppress(Timeout): + third.acquire(blocking=False) + overlaps.append(third.is_locked) + third.release() + polling.clear() + return result + + def peer_breaks_and_holder_acquires() -> None: + stale_lock.unlink() + holder.acquire() + + mocker.patch.object(os, point, new=third_contender_polls) + on_break_recheck(stale_lock, peer_breaks_and_holder_acquires) + with pytest.raises(Timeout): + SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() + + assert (any(overlaps), holder.is_lock_held_by_us) == (False, True) + holder.release() + + +@pytest.mark.skipif(sys.platform != "win32", reason="only Windows renames a marker aside") +def test_break_lock_file_put_back_never_replaces_a_marker_at_the_path( # pragma: win32 cover stale_lock: Path, mocker: MockerFixture ) -> None: rename: Final = Path.rename @@ -125,40 +167,320 @@ def peer_recreates_then_third_creates(source: Path, target: str) -> Path: @NEEDS_SYMLINK -def test_break_lock_file_leaves_a_swapped_in_symlink_aside( # pragma: needs symlink - stale_lock: Path, mocker: MockerFixture +@pytest.mark.usefixtures("break_route") +def test_break_lock_file_never_links_a_symlink_swapped_in_before_the_take( # pragma: needs symlink + stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] ) -> None: (target := stale_lock.with_name("target")).write_text("victim", encoding="utf-8") - rename: Final = Path.rename - def peer_swaps_in_a_symlink_then_rename(source: Path, destination: str) -> Path: - source.unlink() - source.symlink_to(target) - return rename(source, destination) + def peer_swaps_in_a_symlink() -> None: + stale_lock.unlink() + stale_lock.symlink_to(target) - mocker.patch.object(Path, "rename", autospec=True, side_effect=peer_swaps_in_a_symlink_then_rename) + on_break_recheck(stale_lock, peer_swaps_in_a_symlink) with pytest.raises(Timeout): SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() - # Linking the symlink back would follow it and publish the victim file's inode at the lock path. - assert ([path.is_symlink() for path in stale_lock.parent.glob("test.lock.break.*")], stale_lock.exists()) == ( - [True], - False, - ) + # Linking a symlink back with link's default would publish the target's inode at the lock path. + assert (target.stat().st_nlink, target.read_text(encoding="utf-8")) == (1, "victim") -def test_break_lock_file_lets_acquire_retry_after_the_marker_vanishes(stale_lock: Path, mocker: MockerFixture) -> None: - rename: Final = Path.rename +@NEEDS_SYMLINK +def test_break_lock_file_never_links_a_symlink_swapped_in_under_the_break_name( # pragma: needs symlink + stale_lock: Path, mocker: MockerFixture +) -> None: + (target := stale_lock.with_name("target")).write_text("private", encoding="utf-8") + lstat: Final = os.lstat + + def peer_recreates_then_swaps_in_a_symlink(path: str, *, dir_fd: int | None = None) -> os.stat_result: + if ".break." not in os.fspath(path): + return lstat(path, dir_fd=dir_fd) + _rewrite_in_place(Path(path)) + found: Final = lstat(path, dir_fd=dir_fd) + Path(path).unlink() + Path(path).symlink_to(target) + return found - def holder_releases_then_rename(source: Path, target: str) -> Path: - source.unlink() - return rename(source, target) + mocker.patch("os.lstat", side_effect=peer_recreates_then_swaps_in_a_symlink) + with pytest.raises(Timeout): + SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() + + assert target.stat().st_nlink == 1 + + +@pytest.mark.skipif(sys.platform == "win32", reason="the liveness probe of a Windows PID does not call os.kill") +@STALE_BREAKERS +def test_stale_break_spares_a_successor_on_the_freed_inode_number( # pragma: win32 no cover + tmp_path: Path, + mocker: MockerFixture, + reuse_inode: Callable[[Path, os.stat_result], None], + lock_type: type[SoftFileLock], + record: str, +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + stale: Final = lock.lstat() + + def owner_gone_and_successor_acquired(pid: int, signal: int) -> None: + # Between the stale read and the break the marker goes and the next holder's marker takes the freed inode + # number, created in the same timestamp tick. + lock.unlink() + lock.write_text("live", encoding="utf-8") + os.utime(lock, ns=(stale.st_atime_ns, stale.st_mtime_ns)) + reuse_inode(lock, stale) + raise ProcessLookupError(ESRCH, f"no process {pid} for signal {signal}") + + mocker.patch("filelock._identity.os.kill", side_effect=owner_gone_and_successor_acquired) + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + mocker.stopall() + + assert lock.read_text(encoding="utf-8") == "live" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot rename a file held open, so it pins nothing") +@STALE_BREAKERS +def test_stale_break_skips_a_marker_swapped_in_after_the_pin( # pragma: win32 no cover + tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + fstat: Final = os.fstat + swapped: list[bool] = [] + + def pin_then_peer_swaps_the_marker(fd: int) -> os.stat_result: + found: Final = fstat(fd) + if not swapped: + swapped.append(True) + (replacement := lock.with_name("replacement")).write_text(record, encoding="utf-8") + replacement.replace(lock) + return found + + mocker.patch("os.fstat", side_effect=pin_then_peer_swaps_the_marker) + rename: Final = mocker.spy(Path, "rename") + with pytest.raises(Timeout): + lock_type(lock).acquire(blocking=False) + mocker.stopall() + + # Nothing keeps the swapped-in node's inode allocated, so its number proves nothing by the time of a break. + assert (lock.read_text(encoding="utf-8"), rename.call_count) == (record, 0) - mocker.patch.object(Path, "rename", autospec=True, side_effect=holder_releases_then_rename) + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot rename a file held open, so it pins nothing") +@STALE_BREAKERS +def test_stale_break_reclaims_a_marker_it_cannot_pin( # pragma: win32 no cover + tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str +) -> None: + # Without O_PATH a symlink or a socket cannot be opened, and the check falls back to the number it read. + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + open_: Final = os.open + pinned: list[bool] = [] + + def pin_refused(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: + # The pin is the first open of the existing marker; the read after it opens the file for real. + if os.fspath(path) == os.fspath(lock) and not flags & os.O_CREAT and not pinned: + pinned.append(True) + raise OSError(ELOOP, "Too many levels of symbolic links", os.fspath(path)) + return open_(path, flags, mode, dir_fd=dir_fd) + + mocker.patch("os.open", side_effect=pin_refused) + with lock_type(lock, timeout=1) as held: + assert held.is_locked + + +@STALE_BREAKERS +def test_stale_break_lets_acquire_retry_after_the_marker_vanishes_before_the_pin( + tmp_path: Path, mocker: MockerFixture, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + open_: Final = os.open + released: list[bool] = [] + + def holder_releases_then_open(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: + if os.fspath(path) == os.fspath(lock) and not flags & os.O_CREAT and not released: + released.append(True) + lock.unlink() + return open_(path, flags, mode, dir_fd=dir_fd) + + mocker.patch("os.open", side_effect=holder_releases_then_open) + with lock_type(lock, timeout=1) as held: + assert held.is_locked + + +@pytest.mark.usefixtures("break_route") +def test_break_lock_file_lets_acquire_retry_after_the_marker_vanishes( + stale_lock: Path, on_break_recheck: Callable[[Path, Callable[[], None]], None] +) -> None: + on_break_recheck(stale_lock, stale_lock.unlink) with SoftFileLock(stale_lock, timeout=1): assert stale_lock.read_text(encoding="utf-8").splitlines()[0] == str(os.getpid()) +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") +@pytest.mark.parametrize( + "lifetime", + [pytest.param(None, id="stale-take-over"), pytest.param(0.1, id="expiry-placeholder")], +) +def test_stale_take_over_keeps_the_lock_when_a_rename_replaces_its_marker( # pragma: win32 no cover + stale_lock: Path, mocker: MockerFixture, lifetime: float | None +) -> None: + os.utime(stale_lock, (0, 0)) + lstat: Final = os.lstat + replaced: list[bool] = [] + + def peer_replaces_the_swapped_in_file(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + found: Final = lstat(path, dir_fd=dir_fd) + if ".break." in os.fspath(path) and not replaced: + replaced.append(True) + (other := stale_lock.with_name("other")).write_text("other", encoding="utf-8") + other.replace(stale_lock) + return found + + mocker.patch.object(os, "lstat", new=peer_replaces_the_swapped_in_file) + lock: Final = SoftFileLock(stale_lock, lifetime=lifetime) + lock.acquire(blocking=False) + mocker.stopall() + held: Final = lock.is_locked + lock.release() + + assert (held, stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("*.break.*"))) == ( + True, + "other", + [], + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_remove_own_file_placeholder_names_this_process( + tmp_path: Path, mocker: MockerFixture +) -> None: # pragma: win32 no cover + mocker.patch("filelock._soft.os.fchmod", side_effect=OSError(EIO, "Input/output error")) + exchange: Final = filelock._util.exchange_names + placeholders: Final[list[bytes]] = [] + + def record_the_placeholder(source: str, target: str) -> bool: + placeholders.append(Path(source).read_bytes()) + return exchange(source, target) + + mocker.patch("filelock._util.exchange_names", side_effect=record_the_placeholder) + with pytest.raises(OSError, match="Input/output error"): + SoftFileLock(tmp_path / "test.lock", mode=0o600).acquire() + + assert placeholders == [f"filelock/break\n{os.getpid()}\n".encode()] + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@STALE_BREAKERS +def test_stale_break_takes_the_lock_over_in_one_poll( # pragma: win32 no cover + tmp_path: Path, lock_type: type[SoftFileLock], record: str +) -> None: + (lock := tmp_path / "test.lock").write_text(record, encoding="utf-8") + contender: Final = lock_type(lock) + contender.acquire(blocking=False) + + assert (contender.is_lock_held_by_us, list(tmp_path.glob("test.lock.break.*"))) == (True, []) + contender.release() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows pins nothing") +def test_stale_break_spares_a_successor_on_the_freed_inode_number_across_the_exchange( # pragma: win32 no cover + stale_lock: Path, + mocker: MockerFixture, + reuse_inode: Callable[[Path, os.stat_result], None], + on_break_recheck: Callable[[Path, Callable[[], None]], None], +) -> None: + stale: Final = stale_lock.lstat() + + def successor_takes_the_freed_number() -> None: + # A peer broke the stale marker after the recheck and the next holder's marker got its inode number, created in + # the same timestamp tick, so only the pinned inode tells the two apart once the exchange has taken it. + stale_lock.unlink() + stale_lock.write_text("live", encoding="utf-8") + os.utime(stale_lock, ns=(stale.st_atime_ns, stale.st_mtime_ns)) + reuse_inode(stale_lock, stale) + + on_break_recheck(stale_lock, successor_takes_the_freed_number) + with pytest.raises(Timeout): + SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() + + assert (stale_lock.read_text(encoding="utf-8"), list(stale_lock.parent.glob("test.lock.break.*"))) == ("live", []) + + +@pytest.mark.skipif(sys.platform != "win32", reason="only Windows renames a marker aside") +def test_break_lock_file_leaves_a_recreated_marker_aside_where_links_are_refused( # pragma: win32 cover + stale_lock: Path, mocker: MockerFixture, on_break_recheck: Callable[[Path, Callable[[], None]], None] +) -> None: + mocker.patch("os.link", side_effect=PermissionError(1, "Operation not permitted")) + on_break_recheck(stale_lock, lambda: _rewrite_in_place(stale_lock)) + with pytest.raises(Timeout): + SoftFileLock(stale_lock).acquire(blocking=False) + mocker.stopall() + + assert [path.read_text(encoding="utf-8") for path in stale_lock.parent.glob("test.lock.break.*")] == ["live"] + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +@pytest.mark.parametrize( + "error", + [ + pytest.param(EINVAL, id="filesystem-without-exchange"), + pytest.param(EACCES, id="exchange-denied"), + ], +) +def test_break_lock_file_leaves_the_marker_where_the_exchange_fails( # pragma: win32 no cover + stale_lock: Path, mocker: MockerFixture, error: int +) -> None: + # NFS and SMB refuse the exchange; renaming the marker aside instead would empty the path beside a live holder. + renamer: Final = mocker.MagicMock(return_value=error) + mocker.patch("filelock._util._rename_call", return_value=(renamer, 2)) + with pytest.raises(Timeout): + SoftFileLock(stale_lock).acquire(blocking=False) + + assert ( + stale_lock.read_text(encoding="utf-8"), + list(stale_lock.parent.glob("test.lock.break.*")), + renamer.called, + ) == ( + DEAD_HOLDER, + [], + True, + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows cannot exchange two names") +def test_stale_take_over_interrupted_before_the_check_puts_the_marker_back( # pragma: win32 no cover + stale_lock: Path, mocker: MockerFixture +) -> None: + # Between the exchange and the check the lock path names the contender's own marker; an interrupt there must not + # leave it, naming a live PID that holds nothing, nor keep the marker it took. + lstat: Final = os.lstat + interrupted: list[bool] = [] + + def interrupt_after_the_exchange(path: str | os.PathLike[str], *, dir_fd: int | None = None) -> os.stat_result: + if ".break." in os.fspath(path) and not interrupted: + interrupted.append(True) + raise KeyboardInterrupt + return lstat(path, dir_fd=dir_fd) + + mocker.patch.object(os, "lstat", new=interrupt_after_the_exchange) + lock: Final = SoftFileLock(stale_lock) + with pytest.raises(KeyboardInterrupt): + lock.acquire(blocking=False) + mocker.stopall() + + assert ( + stale_lock.read_text(encoding="utf-8"), + list(stale_lock.parent.glob("test.lock.break.*")), + lock.is_locked, + ) == ( + DEAD_HOLDER, + [], + False, + ) + + def test_break_lock_file_break_name_is_unguessable(stale_lock: Path, mocker: MockerFixture) -> None: # A second breaker in this process could compute .break. and rename a recreated live lock onto it # between our lstat and unlink. Were that our break name, our unlink would delete the live lock. @@ -173,8 +495,8 @@ def peer_moves_a_live_lock_onto_the_guessable_name(path: str | os.PathLike[str]) return result mocker.patch("os.lstat", side_effect=peer_moves_a_live_lock_onto_the_guessable_name) - with pytest.raises(Timeout): - SoftFileLock(stale_lock).acquire(blocking=False) + with SoftFileLock(stale_lock): + pass assert guessable.read_text(encoding="utf-8") == "live"