| from __future__ import annotations |
|
|
| import os |
| import stat |
| import sys |
| import time |
| from contextlib import suppress |
| from errno import EACCES, EEXIST, EPERM |
| from pathlib import Path |
| from typing import Final |
|
|
| from ._api import BaseFileLock, _raise_grouped_errors |
| from ._identity import host_name, owner_is_stale, process_start_token |
| from ._soft_protocol import STRICT_SOFT_SENTINEL_RECORD |
| from ._util import break_lock_file, ensure_directory_exists, raise_on_not_writable_file, write_all |
|
|
| _MALFORMED_LOCK_AGE_THRESHOLD: Final[float] = 2.0 |
| _MAX_LOCK_FILE_SIZE: Final[int] = 1024 |
| _UNLINK_MAX_RETRIES: Final[int] = 10 |
| _MARKER_WITH_START_TOKEN_LINE_COUNT: Final[int] = 3 |
|
|
|
|
| class SoftFileLock(BaseFileLock): |
| """ |
| Cooperative file lock based on a shared existence marker. |
| |
| Unlike :class:`UnixFileLock <filelock.UnixFileLock>` and :class:`WindowsFileLock <filelock.WindowsFileLock>`, this |
| lock does not use OS-level locking primitives. Instead, it creates the lock file with ``O_CREAT | O_EXCL`` and |
| treats its existence as the lock indicator. The filesystem must provide coherent exclusive creation and directory |
| updates to each participating process. A crash can leave the marker behind. |
| |
| The marker contains the holder's PID and hostname. A contender may remove it when it can no longer find a same-host |
| process with that PID. A configured :attr:`~filelock.BaseFileLock.lifetime` also permits removal based on marker |
| age, including while the holder remains alive. Age-based expiry can overlap protected operations and does not |
| provide strict mutual exclusion. |
| |
| """ |
|
|
| |
| _lifetime_supported: bool = True |
|
|
| |
| _lifetime_replacements: tuple[str, str] | None = ("StrictSoftFileLock", "SoftFileLease") |
|
|
| |
| _preserve_lock_file_supported: bool = False |
|
|
| |
| _on_acquired_supported: bool = False |
|
|
| def _acquire(self) -> None: |
| raise_on_not_writable_file(self.lock_file) |
| ensure_directory_exists(self.lock_file) |
| |
| |
| flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC |
| if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: |
| flags |= o_nofollow |
| try: |
| fd = os.open(self.lock_file, flags, self._open_mode()) |
| except OSError as exception: |
| if not ( |
| exception.errno == EEXIST or (exception.errno == EACCES and sys.platform == "win32") |
| ): |
| raise |
| self._try_break_stale_lock() |
| return |
| self._mark_descriptor_pending(fd) |
| self._publish_held_marker(fd) |
|
|
| def _publish_held_marker(self, fd: int) -> None: |
| |
| |
| |
| identity: tuple[int, int] | None = None |
| try: |
| identity = _file_identity(os.fstat(fd)) |
| self._write_lock_info(fd) |
| except BaseException: |
| self._mark_descriptor_released() |
| os.close(fd) |
| with suppress(OSError): |
| if identity is not None and _file_identity(os.lstat(self.lock_file)) == identity: |
| Path(self.lock_file).unlink() |
| raise |
| self._mark_descriptor_owned(fd, identity) |
|
|
| def _try_break_stale_lock(self) -> None: |
| with suppress(OSError, ValueError): |
| content, mtime, ino = _read_lock_file(self.lock_file) |
| if content == STRICT_SOFT_SENTINEL_RECORD: |
| return |
| holder = _parse_lock_holder(content) |
|
|
| if holder is None: |
| |
| |
| |
| if time.time() - mtime >= _MALFORMED_LOCK_AGE_THRESHOLD: |
| break_lock_file(self.lock_file, mtime, ino) |
| return |
|
|
| if owner_is_stale(*holder): |
| break_lock_file(self.lock_file, mtime, ino) |
|
|
| @staticmethod |
| def _write_lock_info(fd: int) -> None: |
| |
| |
| |
| info = f"{os.getpid()}\n{host_name()}\n" |
| if (token := process_start_token(os.getpid())) is not None: |
| info += f"{token}\n" |
| write_all(fd, info.encode()) |
|
|
| @property |
| def pid(self) -> int | None: |
| """ |
| The PID of the process holding this lock, read from the lock file. |
| |
| :returns: the PID as an integer, or ``None`` if the lock file does not exist or cannot be parsed |
| |
| """ |
| with suppress(OSError, ValueError): |
| holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0]) |
| if holder is not None: |
| return holder[0] |
| return None |
|
|
| @property |
| def is_lock_held_by_us(self) -> bool: |
| """ |
| Whether this lock is held by the current process. |
| |
| :returns: ``True`` if the lock file exists and names the current process's PID and hostname |
| |
| """ |
| with suppress(OSError, ValueError): |
| holder = _parse_lock_holder(_read_lock_file(self.lock_file)[0]) |
| if holder is not None: |
| pid, hostname, _ = holder |
| return pid == os.getpid() and hostname == host_name() |
| return False |
|
|
| def break_lock(self) -> None: |
| """Forcibly break the lock by removing the lock file, regardless of who holds it.""" |
| with suppress(OSError): |
| Path(self.lock_file).unlink() |
|
|
| def _release(self) -> None: |
| fd = self._context.lock_file_fd |
| assert fd is not None |
| |
| |
| |
| identity: tuple[int, int] | None = None |
| with suppress(OSError): |
| identity = _file_identity(os.fstat(fd)) |
| |
| |
| self._mark_descriptor_released() |
| try: |
| self._close_released_fd(fd, default_suppresses=False) |
| |
| except BaseException as close_error: |
| try: |
| self._unlink_held_marker(identity) |
| except BaseException as cleanup_error: |
| _raise_grouped_errors( |
| "lock descriptor close and marker cleanup both failed", |
| close_error, |
| cleanup_error, |
| ) |
| raise |
| self._unlink_held_marker(identity) |
|
|
| def _unlink_held_marker(self, identity: tuple[int, int] | None) -> None: |
| if identity is None: |
| return |
| if sys.platform == "win32": |
| self._windows_unlink_if_ours(identity) |
| else: |
| with suppress(OSError): |
| if _file_identity(os.lstat(self.lock_file)) == identity: |
| Path(self.lock_file).unlink() |
|
|
| def _windows_unlink_if_ours(self, identity: tuple[int, int]) -> None: |
| retry_delay = 0.001 |
| for attempt in range(_UNLINK_MAX_RETRIES): |
| |
| |
| try: |
| if _file_identity(os.lstat(self.lock_file)) != identity: |
| return |
| Path(self.lock_file).unlink() |
| except OSError as exc: |
| if exc.errno not in {EACCES, EPERM}: |
| return |
| if attempt < _UNLINK_MAX_RETRIES - 1: |
| time.sleep(retry_delay) |
| retry_delay *= 2 |
| else: |
| return |
|
|
|
|
| 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]: |
| |
| |
| |
| |
| st = os.lstat(path) |
| if not stat.S_ISREG(st.st_mode): |
| return None, st.st_mtime, st.st_ino |
| |
| |
| |
| fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) |
| try: |
| st = os.fstat(fd) |
| if not stat.S_ISREG(st.st_mode): |
| return None, st.st_mtime, st.st_ino |
| 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 |
|
|
|
|
| def _parse_lock_holder(content: str | None) -> tuple[int, str, int | None] | None: |
| |
| |
| |
| |
| if not content or len(lines := content.strip().splitlines()) not in {2, 3}: |
| return None |
| try: |
| pid = int(lines[0]) |
| start_token = int(lines[2]) if len(lines) == _MARKER_WITH_START_TOKEN_LINE_COUNT else None |
| except ValueError: |
| return None |
| |
| |
| |
| |
| if not 1 <= pid <= 2**31 - 1: |
| return None |
| return pid, lines[1], start_token |
|
|
|
|
| __all__ = [ |
| "SoftFileLock", |
| ] |
|
|