filelock-3.32.2/tox.toml0000644000000000000000000001406213615410400012023 0ustar00requires = [ "tox>=4.52" ] env_list = [ "3.15", "3.14", "3.13", "3.12", "3.11", "3.10", "3.14t", "3.15t", "coverage", "docs", "fix", "pkg_meta", "type" ] skip_missing_interpreters = true [env_run_base] description = "run the tests with pytest under {env_name}" package = "wheel" wheel_build_env = ".pkg" dependency_groups = [ "test" ] pass_env = [ "PYTEST_*", "SSL_CERT_FILE" ] set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } # tasks holds the coverage_pragmas plugin, which coverage imports by name in this process and in the subprocesses it # patches, so it has to resolve from the environment rather than from pytest's sys.path. set_env.PYTHONPATH = "{tox_root}{/}tasks" # No --cov-context: coverage refuses its sys.monitoring core when contexts are configured, because that core does not # implement them, but pytest-cov switches contexts behind that guard. The unsupported pair silently drops lines the # suite ran, and it cost BaseAsyncFileLock.__del__'s no-loop path a flaky 99.98% on the 3.14 jobs. commands = [ [ "pytest", "--durations", "10", "--junitxml", "{env_tmp_dir}{/}junit.xml", { replace = "posargs", default = [ "--no-cov-on-fail", "--cov", "{env_site_packages_dir}{/}filelock", "--cov", "{tox_root}{/}tests", "--cov-config", "{tox_root}{/}pyproject.toml", "--cov-report", "term-missing:skip-covered", "--cov-report", "html:{env_tmp_dir}{/}htmlcov", "--cov-report", "xml:{env_tmp_dir}{/}coverage.xml", ], extend = true }, "tests", ], { replace = "posargs", default = [ [ "diff-cover", "--compare-branch", { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, "{env_tmp_dir}{/}coverage.xml", ], ], extend = true }, ] [env.coverage] description = "combine coverage files and generate diff (against DIFF_AGAINST defaulting to origin/main)" skip_install = true dependency_groups = [ "coverage" ] parallel_show_output = true pass_env = [ { replace = "ref", of = [ "env_run_base", "pass_env", ], extend = true }, "DIFF_AGAINST", ] set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage" } # Exclusions are applied when the report is rendered, so the reporting env needs the plugin too. set_env.PYTHONPATH = "{tox_root}{/}tasks" commands = [ [ "coverage", "combine" ], [ "coverage", "report", "--skip-covered", "--show-missing" ], [ "coverage", "xml", "-o", "{env_tmp_dir}{/}coverage.xml" ], [ "coverage", "html", "-d", "{work_dir}{/}htmlcov" ], [ "diff-cover", "--compare-branch", { replace = "env", name = "DIFF_AGAINST", default = "origin/main" }, "{env_tmp_dir}{/}coverage.xml", ], ] depends = [ "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t" ] [env.docs] description = "build documentation" dependency_groups = [ "docs" ] commands = [ [ "sphinx-build", "-d", "{env_tmp_dir}{/}docs_tree", "docs", "{env:READTHEDOCS_OUTPUT:{work_dir}{/}docs_out}/html", "--color", "-b", "html", { replace = "posargs", default = [], extend = true }, "-W", ], [ "python", "-c", 'print(r"documentation available under file://{work_dir}{/}docs_out{/}html{/}index.html")', ], ] [env.fix] description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" skip_install = true dependency_groups = [ "fix" ] pass_env = [ { replace = "ref", of = [ "env_run_base", "pass_env", ], extend = true }, "DISABLE_PRE_COMMIT_UV_PATCH", "PROGRAMDATA", ] commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs", extend = true } ] ] [env.pkg_meta] description = "check that the long description is valid" skip_install = true dependency_groups = [ "pkg_meta" ] commands = [ [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", ".", ], [ "twine", "check", "{env_tmp_dir}{/}*", ], [ "check-wheel-contents", "--no-config", "{env_tmp_dir}", ], ] [env.type] description = "run type check on code base" dependency_groups = [ "type" ] commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ] [env.bench] description = "measure the performance matrix and print it as a report (a baseline, not a gate)" package = "wheel" wheel_build_env = ".pkg" commands = [ [ "python", "tasks{/}benchmark.py" ] ] [env.dev] description = "dev environment with all deps at {envdir}" package = "editable" dependency_groups = [ "dev" ] commands = [ [ "uv", "pip", "list", "--format=columns" ], [ "python", "-c", 'print(r"{env_python}")' ] ] [env.release] description = "build the changelog from the news fragments for the given version, e.g. tox r -e release -- 3.30.0" skip_install = true dependency_groups = [ "release" ] commands = [ [ "towncrier", "build", "--yes", "--version", "{posargs}" ] ] [env."3.14"] # Set COVERAGE_FILE again: overriding set_env here drops the value inherited from env_run_base, and without it # coverage writes .coverage in the repo root instead of {work_dir}/.coverage.3.14, so the CI rename cannot find it. set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } set_env.FILELOCK_OLD_CLIENT_PATH = "{env_tmp_dir}{/}filelock-3.20.0" set_env.PYTHONPATH = "{tox_root}{/}tasks" commands_pre = [ [ "uv", "pip", "install", "--target", "{env_tmp_dir}{/}filelock-3.20.0", "--no-deps", "--reinstall", "filelock==3.20.0", ], ] # tox invents an env on the fly only for a py or pypy factor, so GraalPy has to be declared to be selectable at all. It # stays out of env_list because, like PyPy, it is a CI-only check that runs unmeasured and feeds no coverage data: the # 100% gate remains CPython's. Its job is to keep the capability probes the suite gates on honest on a second runtime. [env."graalpy3.11"] description = "run the tests with pytest under {env_name}, unmeasured" filelock-3.32.2/src/filelock/__init__.py0000644000000000000000000000661513615410400015011 0ustar00""" A platform independent file lock that supports the with-statement. .. autodata:: filelock.__version__ :no-value: """ from __future__ import annotations import sys import warnings from typing import TYPE_CHECKING, Final from ._api import AcquireReturnProxy, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, LockOptions from ._descriptor import lock_descriptor, unlock_descriptor from ._error import LeaseSettingsMismatch, SoftFileLockLifetimeWarning, SoftFileLockProtocolError, Timeout from ._lease import LeaseCompromise, SoftFileLease from ._marker import MarkerSoftFileLock, OwnerRecord if TYPE_CHECKING: from ._async_read_write import ( AsyncAcquireReadWriteReturnProxy, AsyncReadWriteLock, ) from ._read_write import ReadWriteLock else: try: from ._async_read_write import AsyncAcquireReadWriteReturnProxy, AsyncReadWriteLock from ._read_write import ReadWriteLock except ImportError: # pragma: lacks sqlite3 AsyncAcquireReadWriteReturnProxy = None AsyncReadWriteLock = None ReadWriteLock = None from ._soft import SoftFileLock from ._soft_rw import AsyncAcquireSoftReadWriteReturnProxy, AsyncSoftReadWriteLock, SoftReadWriteLock from ._strict import StrictSoftFileClaim, StrictSoftFileClaimState, StrictSoftFileLock from ._unix import UnixFileLock, has_fcntl from ._windows import WindowsFileLock from .asyncio import ( AsyncAcquireReturnProxy, AsyncSoftFileLease, AsyncSoftFileLock, AsyncStrictSoftFileLock, AsyncUnixFileLock, AsyncWindowsFileLock, BaseAsyncFileLock, ) from .version import version #: version of the project as a string __version__: Final[str] = version if sys.platform == "win32": # pragma: win32 cover _FileLock: type[BaseFileLock] = WindowsFileLock _AsyncFileLock: type[BaseAsyncFileLock] = AsyncWindowsFileLock else: # pragma: win32 no cover # ruff:ignore[collapsible-else-if] # the else carries the win32 no-cover pragma if has_fcntl: _FileLock: type[BaseFileLock] = UnixFileLock _AsyncFileLock: type[BaseAsyncFileLock] = AsyncUnixFileLock else: _FileLock = SoftFileLock _AsyncFileLock = AsyncSoftFileLock warnings.warn("only soft file lock is available", stacklevel=2) if TYPE_CHECKING: FileLock = SoftFileLock AsyncFileLock = AsyncSoftFileLock else: #: Alias for the lock, which should be used for the current platform. FileLock = _FileLock AsyncFileLock = _AsyncFileLock __all__ = [ "AcquireReturnProxy", "AsyncAcquireReadWriteReturnProxy", "AsyncAcquireReturnProxy", "AsyncAcquireSoftReadWriteReturnProxy", "AsyncFileLock", "AsyncReadWriteLock", "AsyncSoftFileLease", "AsyncSoftFileLock", "AsyncSoftReadWriteLock", "AsyncStrictSoftFileLock", "AsyncUnixFileLock", "AsyncWindowsFileLock", "BaseAsyncFileLock", "BaseFileLock", "CloseErrorPolicy", "ContextErrorPolicy", "FileLock", "LeaseCompromise", "LeaseSettingsMismatch", "LockOptions", "MarkerSoftFileLock", "OwnerRecord", "ReadWriteLock", "SoftFileLease", "SoftFileLock", "SoftFileLockLifetimeWarning", "SoftFileLockProtocolError", "SoftReadWriteLock", "StrictSoftFileClaim", "StrictSoftFileClaimState", "StrictSoftFileLock", "Timeout", "UnixFileLock", "WindowsFileLock", "__version__", "lock_descriptor", "unlock_descriptor", ] filelock-3.32.2/src/filelock/_api.py0000644000000000000000000024052713615410400014164 0ustar00from __future__ import annotations import contextlib import inspect import logging import math import os import secrets import sys import time import warnings from abc import ABCMeta, abstractmethod from collections.abc import Callable, Hashable from contextlib import contextmanager from dataclasses import dataclass from itertools import count, starmap from threading import Condition, RLock, get_ident, local from typing import TYPE_CHECKING, Final, Literal, NoReturn, TypedDict, TypeVar, cast from weakref import WeakKeyDictionary, WeakValueDictionary from ._error import SoftFileLockLifetimeWarning, Timeout from ._util import break_lock_file #: 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. _UNSET_FILE_MODE: Final[int] = -1 #: Ceiling on the retry counter used as a power of two, so a long contended wait cannot overflow the backoff multiply. _MAX_BACKOFF_EXPONENT: Final[int] = 20 #: How a context manager reconciles a body failure with a release failure on exit (see the property of this name). ContextErrorPolicy = Literal["chain", "group"] _CONTEXT_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"chain", "group"}) #: What a descriptor-owning backend does with an ``os.close`` failure after relinquishing ownership (see the property). CloseErrorPolicy = Literal["default", "raise", "suppress"] _CLOSE_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"default", "raise", "suppress"}) if TYPE_CHECKING: from collections.abc import Generator from types import TracebackType from typing import Protocol from ._read_write import ReadWriteLock from ._soft_rw import SoftReadWriteLock class _ForkResettable(Protocol): def _reset_after_fork_in_child(self) -> None: ... class _ForkDescriptorOwner(Protocol): def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: ... # Matched against the class object itself rather than `type[...]` of it. A metaclass supplies this method to the # class while leaving instances without it, so a `type[_ForkResettableClass]` bound rejects `ReadWriteLock`. class _ForkResettableClass(Protocol): def _reset_class_after_fork(self) -> None: ... class _RegisterAtFork(Protocol): def __call__( self, *, before: Callable[[], None] | None = None, after_in_parent: Callable[[], None] | None = None, after_in_child: Callable[[], None] | None = None, ) -> None: ... if sys.version_info >= (3, 11): # pragma: no cover (py311+) from typing import Self else: # pragma: no cover ( type[BaseException]: # BaseExceptionGroup is a builtin on 3.11+; on 3.10 it needs the exceptiongroup backport. filelock keeps zero # runtime dependencies, so the backport is imported lazily rather than required, and only group mode needs it. if sys.version_info >= (3, 11): # pragma: no cover (py311+) return BaseExceptionGroup # ruff:ignore[undefined-name] # builtin on 3.11+ # Alias the import so BaseExceptionGroup above stays the builtin rather than an unbound local of this function. from exceptiongroup import ( # ruff:ignore[import-outside-top-level] # pragma: no cover ( NoReturn: errors = (first_error, second_error, *additional_errors) _detach_grouped_contexts(errors) group = _exception_group_cls()(message, errors) if marker is not None: setattr(group, marker[0], marker[1]) raise group from None def _detach_grouped_contexts(errors: tuple[BaseException, ...]) -> None: seen: set[int] = set() pending = list(errors) while pending: error = pending.pop() if id(error) in seen: continue seen.add(id(error)) if (context := error.__context__) is not None and ( context is error or _same_exception_tree(error, context) or any(context is root or _contains_exception(root, context) for root in errors) ): error.__context__ = None elif context is not None: pending.append(context) if error.__cause__ is not None: pending.append(error.__cause__) if isinstance(error, _exception_group_cls()): pending.extend(cast("_ExceptionGroupProtocol", error).exceptions) def _same_exception_tree(first: BaseException, second: BaseException) -> bool: pending = [(first, second)] seen: set[tuple[int, int]] = set() while pending: first_error, second_error = pending.pop() if first_error is second_error: continue if (pair := (id(first_error), id(second_error))) in seen: continue seen.add(pair) if ( type(first_error) is not type(second_error) or not isinstance(first_error, _exception_group_cls()) or not isinstance(second_error, _exception_group_cls()) ): return False first_group = cast("_ExceptionGroupProtocol", first_error) second_group = cast("_ExceptionGroupProtocol", second_error) if first_group.message != second_group.message or len(first_group.exceptions) != len(second_group.exceptions): return False pending.extend(zip(first_group.exceptions, second_group.exceptions, strict=True)) return True def _contains_exception(error: BaseException, target: BaseException | None) -> bool: if target is None or not isinstance(error, _exception_group_cls()): return False pending = list(cast("_ExceptionGroupProtocol", error).exceptions) seen: set[int] = set() while pending: child = pending.pop() if child is target: return True if id(child) in seen: continue seen.add(id(child)) if isinstance(child, _exception_group_cls()): pending.extend(cast("_ExceptionGroupProtocol", child).exceptions) return False def _append_exception_context(error: BaseException, context: BaseException) -> None: if _exception_graph_contains(error, context) or _exception_graph_contains(context, error): return if error.__context__ is None: error.__context__ = context return tail = error seen: set[int] = set() while id(tail) not in seen: seen.add(id(tail)) if (next_error := tail.__cause__ if tail.__cause__ is not None else tail.__context__) is None: tail.__context__ = context return tail = next_error def _exception_graph_contains(error: BaseException, target: BaseException) -> bool: pending = [error] seen: set[int] = set() while pending: current = pending.pop() if current is target: return True if id(current) in seen: # pragma: no cover - arbitrary caller exceptions can contain cycles continue seen.add(id(current)) if current.__cause__ is not None: pending.append(current.__cause__) if current.__context__ is not None: pending.append(current.__context__) if isinstance(current, _exception_group_cls()): pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) return False def _grouped_errors( error: BaseException, message: str, marker: tuple[str, _MarkerValue] ) -> tuple[BaseException, ...] | None: if not isinstance(error, _exception_group_cls()): return None group = cast("_ExceptionGroupProtocol", error) return group.exceptions if group.message == message and getattr(group, marker[0], None) is marker[1] else None if TYPE_CHECKING: class _ExceptionGroupProtocol(Protocol): @property def message(self) -> str: ... @property def exceptions(self) -> tuple[BaseException, ...]: ... def _raise_chained_errors(first_error: BaseException, second_error: BaseException | None = None) -> NoReturn: if second_error is None: first_context = first_error.__context__ try: raise first_error # ruff:ignore[raise-within-try] # the handler restores caller-supplied context before propagation except BaseException: first_error.__context__ = first_context raise if (second_context := second_error.__context__) is not None and second_context is not first_error: _detach_exception_context(second_context, first_error) _append_exception_context(first_error, second_context) first_context = first_error.__context__ try: raise first_error # ruff:ignore[raise-within-try] # the second raise needs this error as implicit context except BaseException: # ruff:ignore[blind-except] # first_error may be a control-flow exception first_error.__context__ = first_context try: raise second_error # ruff:ignore[raise-within-try] # the handler makes the chain interpreter-independent except BaseException: second_error.__context__ = first_error first_error.__context__ = first_context raise def _detach_exception_context(error: BaseException, target: BaseException) -> None: pending = [error] seen: set[int] = set() while pending: current = pending.pop() if id(current) in seen: continue seen.add(id(current)) if current.__context__ is target: current.__context__ = None elif current.__context__ is not None: pending.append(current.__context__) if current.__cause__ is not None: pending.append(current.__cause__) if isinstance(current, _exception_group_cls()): pending.extend(cast("_ExceptionGroupProtocol", current).exceptions) def _raise_body_and_release(body_error: BaseException, release_error: BaseException) -> NoReturn: # Group mode: surface the body failure and the release failure as sibling leaves instead of letting one hide in the # other's __context__. BaseExceptionGroup returns a plain ExceptionGroup when both leaves subclass Exception, so # ``except*`` and ``except Exception`` still catch them; a BaseException leaf (KeyboardInterrupt, CancelledError) # keeps the group outside ordinary handlers. ``from None`` stops the group itself gaining a redundant __context__. _raise_grouped_errors("lock body and release both failed", body_error, release_error) def _raise_cleanup_errors( message: str, primary_error: BaseException, *cleanup_errors: BaseException | None, ) -> NoReturn: _raise_grouped_errors( message, primary_error, *(error for error in cleanup_errors if error is not None), ) # On Windows os.path.realpath calls CreateFileW with share_mode=0, which blocks concurrent DeleteFileW and causes # livelocks under threaded contention with SoftFileLock. os.path.abspath is purely string-based and avoids this. _resolve_dir: Final[Callable[[str], str]] = os.path.abspath if sys.platform == "win32" else os.path.realpath def _canonical(path: str | os.PathLike[str]) -> str: """ Return one stable key for *path*, collapsing equivalent spellings without following a final symlink. Relative, absolute, and ``./`` spellings of one lock file must map to a single singleton instance, deadlock-registry entry, and removal key. Resolving the whole path with ``realpath`` would follow a final symlink and alias a lock target the backend deliberately rejects, so the registry identity would differ from the backend's. Resolving only the parent directory and re-appending the literal final component collapses the equivalent spellings while keeping a final symlink a distinct key. On Windows the parent is resolved with ``abspath`` so junctions and reparse points are not followed either. """ parent, name = os.path.split(os.fspath(path)) return os.path.join(_resolve_dir(parent or os.curdir), name) # ruff:ignore[os-path-join] # string join matches abspath/realpath class _ThreadLocalRegistry(local): def __init__(self) -> None: super().__init__() self.held: dict[Hashable, int] = {} _registry: Final[_ThreadLocalRegistry] = _ThreadLocalRegistry() _T = TypeVar("_T", bound="BaseFileLock") class FileLockMeta(ABCMeta): _instances: WeakValueDictionary[str, BaseFileLock] _instances_lock: RLock _instances_under_construction: set[str] def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters cls: type[_T], lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, **kwargs: _ExtraValue, ) -> _T: _ensure_current_process() lifetime = _resolve_lifetime(lifetime, cls, stacklevel=cls._constructor_lifetime_warning_stacklevel) # Validate before building the instance: a raise inside __init__ would leave a half-constructed object whose # __del__ then trips over the missing context. context_error_policy = _resolve_context_error_policy(context_error_policy) close_error_policy = _resolve_close_error_policy(close_error_policy) preserve_lock_file = _resolve_preserve_lock_file( preserve=preserve_lock_file, supported=cls._preserve_lock_file_supported, cls_name=cls.__name__ ) on_acquired = _resolve_on_acquired(on_acquired, supported=cls._on_acquired_supported, cls_name=cls.__name__) params: dict[str, _LockInitValue | _ExtraValue] = { "timeout": timeout, "mode": mode, "thread_local": thread_local, "blocking": blocking, "is_singleton": is_singleton, "poll_interval": poll_interval, "lifetime": lifetime, "context_error_policy": context_error_policy, "close_error_policy": close_error_policy, "fallback_to_soft": fallback_to_soft, "preserve_lock_file": preserve_lock_file, "on_acquired": on_acquired, **kwargs, } if not is_singleton: return cls._create_instance(lock_file, params) # Look up, build and store under one lock. Without it two threads racing the first construction for a # path both miss the cache and each build their own instance, so callers relying on is_singleton for # reentrant locking across instances end up with two "singletons" and acquire()'s deadlock check then # rejects a legitimate reentrant acquire; the unguarded writes to the WeakValueDictionary are a data # race besides. ReadWriteLock and SoftReadWriteLock already guard their singleton caches this way. # Key the cache on the canonical form so equivalent spellings of one path share a singleton, and it matches the # deadlock-registry key acquire() uses. singleton_key = _canonical(lock_file) with cls._instances_lock: if (instance := cls._instances.get(singleton_key)) is None: if singleton_key in cls._instances_under_construction: # pragma: needs fork msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_registry = cls._instances_under_construction construction_pid = os.getpid() construction_registry.add(singleton_key) try: instance = cls._create_instance(lock_file, params) finally: construction_registry.discard(singleton_key) if os.getpid() != construction_pid: # pragma: needs fork msg = "Lock construction cannot continue after fork; construct a new lock in the child" raise RuntimeError(msg) cls._instances[singleton_key] = instance return instance params_to_check = { "thread_local": (thread_local, instance.is_thread_local()), "timeout": (timeout, instance.timeout), "mode": (mode, instance._context.mode), # ruff:ignore[private-member-access] # compares against the managed instance's own context "blocking": (blocking, instance.blocking), "poll_interval": (poll_interval, instance.poll_interval), "lifetime": (lifetime, instance.lifetime), "context_error_policy": (context_error_policy, instance.context_error_policy), "close_error_policy": (close_error_policy, instance.close_error_policy), "fallback_to_soft": (fallback_to_soft, instance.fallback_to_soft), "preserve_lock_file": (preserve_lock_file, instance.preserve_lock_file), } non_matching_params = { name: (passed_param, set_param) for name, (passed_param, set_param) in params_to_check.items() if passed_param != set_param } # Callables compare by identity, not equality: two equal callables can close over different state, so a # singleton must reject a different hook object even if it compares equal. Keep it out of the scalar dict above. hook_mismatch = on_acquired is not instance.on_acquired if not non_matching_params and not hook_mismatch: return instance # ty: ignore[invalid-return-type] # https://github.com/astral-sh/ty/issues/3231 msg = "Singleton lock instances cannot be initialized with differing arguments" msg += "\nNon-matching arguments: " for param_name, (passed_param, set_param) in non_matching_params.items(): msg += f"\n\t{param_name} (existing lock has {set_param} but {passed_param} was passed)" if hook_mismatch: msg += f"\n\ton_acquired (existing lock has {instance.on_acquired} but {on_acquired} was passed)" raise ValueError(msg) def _create_instance( cls: type[_T], lock_file: str | os.PathLike[str], params: dict[str, _LockInitValue | _ExtraValue] ) -> _T: model = _init_parameter_model(cls) if model.accepts_kwargs: return super().__call__(lock_file, **params) unsupported = sorted( name for name, value in params.items() if name not in model.accepted_params and ((parameter := model.default_params.get(name)) is None or value != parameter.default) ) if unsupported: msg = f"{cls.__name__} does not support non-default lock options: {', '.join(unsupported)}" raise TypeError(msg) # virtualenv narrows a BaseFileLock descendant's signature; omit base defaults it does not accept (#340). return super().__call__( lock_file, **{name: value for name, value in params.items() if name in model.accepted_params}, ) _INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary() def _init_parameter_model(cls: type[BaseFileLock]) -> _InitParameterModel: # A strong cache would keep dynamically created subclasses alive for the process lifetime. with _fork_transition(), _FORK_STATE.parameter_models_lock: if (model := _INIT_PARAMETER_MODELS.get(cls)) is None: parameters = inspect.signature(cls.__init__).parameters.values() model = _InitParameterModel( accepted_params=frozenset( parameter.name for parameter in parameters if parameter.kind in {inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY} ), accepts_kwargs=any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters), default_params={ name: parameter for name, parameter in inspect.signature(type(cls).__call__).parameters.items() if parameter.default is not inspect.Parameter.empty }, ) _INIT_PARAMETER_MODELS[cls] = model return model @dataclass(frozen=True) class _InitParameterModel: accepted_params: frozenset[str] accepts_kwargs: bool default_params: dict[str, inspect.Parameter] def _resolve_lifetime(lifetime: float | None, cls: type[BaseFileLock], *, stacklevel: int) -> float | None: """ Validate ``lifetime`` and drop a value the backend cannot honor. ``lifetime`` is a deliberate age-based lease: a lock file older than ``lifetime`` is broken even while its holder is still alive. Existence locks (:class:`SoftFileLock`) implement that behavior by unlinking a reclaimable pathname, which can overlap a live holder. A native OS lock lives on the inode, so unlinking the pathname by age cannot revoke the kernel lock; a contender would lock a fresh inode and overlap the live holder (#590). Ignore the request with a warning rather than accept a setting that breaks mutual exclusion. """ if lifetime is not None: if isinstance(lifetime, bool) or not isinstance(lifetime, (int, float)): msg = f"lifetime must be a finite non-negative number or None, not {type(lifetime).__name__}" raise TypeError(msg) if lifetime < 0 or (isinstance(lifetime, float) and not math.isfinite(lifetime)): msg = f"lifetime must be finite and non-negative, not {lifetime!r}" raise ValueError(msg) if lifetime is not None and not cls._lifetime_supported: warnings.warn( f"lifetime is ignored for {cls.__name__}: {cls._lifetime_unsupported_reason}; " f"only SoftFileLock supports lifetime-based expiry", stacklevel=stacklevel, ) return None if lifetime is not None and cls._lifetime_replacements is not None: strict_lock, lease = cls._lifetime_replacements warnings.warn( f"{cls.__name__}(lifetime=...) uses age-based expiry and can overlap a live holder; " f"use {lease} for expiry or {strict_lock} for fail-closed locking", SoftFileLockLifetimeWarning, stacklevel=stacklevel, ) return lifetime def _resolve_context_error_policy(policy: str) -> ContextErrorPolicy: if policy not in _CONTEXT_ERROR_POLICIES: msg = f"context_error_policy must be 'chain' or 'group', got {policy!r}" raise ValueError(msg) if policy == "group": # fail fast at construction rather than only when a dual failure happens to occur try: _exception_group_cls() except ImportError as exc: # pragma: no cover # only on 3.10 without the exceptiongroup backport msg = "context_error_policy='group' requires Python 3.11+ or the 'exceptiongroup' backport installed" raise ValueError(msg) from exc return cast("ContextErrorPolicy", policy) def _resolve_close_error_policy(policy: str) -> CloseErrorPolicy: if policy not in _CLOSE_ERROR_POLICIES: msg = f"close_error_policy must be 'default', 'raise', or 'suppress', got {policy!r}" raise ValueError(msg) return cast("CloseErrorPolicy", policy) def _resolve_preserve_lock_file(*, preserve: bool, supported: bool, cls_name: str) -> bool: # An existence lock unlinks its marker to release, so preserving the pathname would defeat unlocking. Reject the # request rather than silently ignore it, since a caller asking for a stable identity must know it cannot be kept. if preserve and not supported: msg = f"preserve_lock_file=True is not supported by {cls_name}: unlinking its marker is how it releases" raise ValueError(msg) return preserve def _resolve_on_acquired( on_acquired: Callable[[int], None] | None, *, supported: bool, cls_name: str ) -> Callable[[int], None] | None: if on_acquired is None: return None # An existence lock stores protocol state in its marker, so a caller writing through the descriptor would corrupt # stale detection and ownership metadata; only native locks lend out the descriptor. if not supported: msg = f"on_acquired is not supported by {cls_name}: only native locks expose the lock descriptor" raise ValueError(msg) # A hook that fails and then also fails to release surfaces both errors as a BaseExceptionGroup. Require that class # at construction rather than at the rare moment both fail, matching how context_error_policy='group' validates. try: _exception_group_cls() except ImportError as exc: # pragma: no cover # only on 3.10 without the exceptiongroup backport msg = "on_acquired requires Python 3.11+ or the 'exceptiongroup' backport for its rollback error path" raise ValueError(msg) from exc return on_acquired class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): # ruff:ignore[too-many-public-methods] # public config properties """ Abstract base class for a file lock object. Provides the common reentrant API and state management. Subclasses implement the locking mechanism (:class:`UnixFileLock `, :class:`WindowsFileLock `, :class:`SoftFileLock `). """ _instances: WeakValueDictionary[str, BaseFileLock] _instances_lock: RLock _instances_under_construction: set[str] #: How the cross-instance deadlock message names the conflicting holder; the async subclass says "task". _deadlock_holder_desc: str = "FileLock instance in this thread" #: Whether an age-based :attr:`lifetime` lease may break this lock. Only existence locks set it (they reclaim by #: unlinking a pathname); native OS locks leave it ``False`` since a kernel lock cannot be revoked by file age. _lifetime_supported: bool = False #: Strict-lock and lease replacements for a backend with legacy age-based expiry. _lifetime_replacements: tuple[str, str] | None = None #: Why a backend that refuses ``lifetime`` cannot honor it, named in the warning that drops the value. _lifetime_unsupported_reason: str = "a native OS lock cannot be broken safely by file age" #: Async construction adds one metaclass frame before lifetime validation. _constructor_lifetime_warning_stacklevel: int = 3 #: Whether :attr:`preserve_lock_file` may be ``True``. Native locks keep the pathname on release, so they support #: it; existence locks unlink their marker to release and reject it. _preserve_lock_file_supported: bool = True #: Whether an :attr:`on_acquired` hook may be set. Native locks lend the descriptor out; existence locks keep #: protocol state in the marker and reject it. _on_acquired_supported: bool = True #: Whether a shared instance serializes its physical acquire and release behind one gate. A backend that publishes #: several files per owner needs it; a single-file backend is atomic and leaves it off to skip the gate entirely. _serialize_transitions: bool = False #: Ceiling in seconds on the jittered backoff between contended acquisition retries. ``0`` keeps the fixed #: poll cadence; a multi-file backend sets it so contending processes desynchronize instead of livelocking. _poll_backoff_cap: float = 0.0 def __init_subclass__(cls, **kwargs: _SubclassValue) -> None: """Give each lock subclass its own singleton registry and lock.""" super().__init_subclass__(**kwargs) cls._instances = WeakValueDictionary() cls._instances_lock = RLock() cls._instances_under_construction = set() _register_fork_class(cls) @classmethod def _reset_class_after_fork(cls) -> None: # pragma: forked child cls._instances = WeakValueDictionary() cls._instances_lock = RLock() cls._instances_under_construction = set() def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = True, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, ) -> None: """ Create a new lock object. :param lock_file: path to the file :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock. :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and default ACLs, preserving POSIX default ACL inheritance in shared directories. :param thread_local: Whether this object's internal context should be thread local or not. If this is set to ``False`` then the lock will be reentrant across threads. When ``True`` (the default), **all fields of the lock's internal context are per-thread**, including the configuration values ``poll_interval``, ``timeout``, ``blocking``, ``mode``, and ``lifetime``. Setting one of these properties from one thread does not change the value seen by another thread; threads that did not perform the write continue to see the value supplied at construction time. If you need configuration values to be visible across threads, construct the lock with ``thread_local=False``. :param blocking: whether the lock should be blocking or not :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock file. This is useful if you want to use the lock object for reentrant locking without needing to pass the same object around. :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value in the acquire method, if no poll_interval value (``None``) is given. :param lifetime: for :class:`SoftFileLock`, the age in seconds after which a waiting process may delete the marker, even while its holder remains alive. This legacy expiry mode does not provide strict mutual exclusion. ``None`` (the default) disables age-based expiry. Native OS locks (:class:`FileLock`) cannot be revoked by file age and ignore a non-``None`` ``lifetime`` with a warning. :param context_error_policy: how a context manager reconciles a failure in its body with a failure while releasing on exit. ``"chain"`` (the default) keeps Python's behavior: the release error propagates with the body error in its ``__context__``. ``"group"`` raises a :class:`BaseExceptionGroup` holding the body error first and the release error second, so neither hides the other. :param close_error_policy: what to do with an ``os.close`` failure after relinquishing descriptor ownership. ``"default"`` keeps each backend's historical behavior (Unix native locks drop a FUSE/Docker ``EIO``; Windows native locks and :class:`SoftFileLock` propagate); ``"raise"`` always propagates the ``OSError``; ``"suppress"`` always ignores it. Held state is released either way. It does not affect unlock failures or lock-file deletion. :param fallback_to_soft: for :class:`UnixFileLock`, whether to switch to :class:`SoftFileLock` when the filesystem's ``flock`` returns ``ENOSYS``. ``True`` (the default) keeps the historical fallback; ``False`` fails closed, letting the ``ENOSYS`` propagate so a caller that needs kernel-enforced locking is never silently downgraded. It has no effect on Windows or :class:`SoftFileLock`. :param preserve_lock_file: for native locks (:class:`FileLock`), whether filelock promises not to unlink the lock pathname on release. ``False`` (the default) keeps each backend's cleanup: Windows removes the lock file, Unix already leaves it. ``True`` keeps a stable file identity for ACLs, auditing, and holder metadata: Windows skips its post-release unlink and Unix refuses to enter the ``ENOSYS`` soft fallback (which releases by unlinking). :class:`SoftFileLock` rejects ``True``. The promise covers filelock's own release path only; it cannot stop another process or the filesystem from removing the pathname. :param on_acquired: for native locks (:class:`FileLock`), a callable invoked with the borrowed lock descriptor once per physical acquisition, after filelock holds the native lock and finished backend initialization but before :meth:`~BaseFileLock.acquire` returns. Recursive acquisitions do not call it again. The callback may read, write, seek, truncate, or set metadata through ``os`` on the descriptor, but must not close, unlock, or take ownership of it, and filelock does not fsync its writes. If it raises, filelock releases the lock and re-raises. :class:`SoftFileLock` rejects the hook. """ self._creator_pid = os.getpid() self._transition_lock = RLock() self._is_thread_local = thread_local self._is_singleton = is_singleton self._context_error_policy = context_error_policy # already validated by the metaclass self._close_error_policy = close_error_policy # already validated by the metaclass self._fallback_to_soft = fallback_to_soft self._preserve_lock_file = preserve_lock_file # already validated by the metaclass self._on_acquired = on_acquired # already validated by the metaclass self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)( lock_file=os.fspath(lock_file), timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, ) _register_fork_object(self) def is_thread_local(self) -> bool: """:returns: a flag indicating if this lock is thread local or not""" return self._is_thread_local @property def is_singleton(self) -> bool: """ A flag indicating if this lock is singleton or not. .. versionadded:: 3.13.0 """ return self._is_singleton @property def context_error_policy(self) -> ContextErrorPolicy: """ How a context manager reconciles a body failure with a release failure on exit. .. versionadded:: 3.30.0 """ return self._context_error_policy @property def close_error_policy(self) -> CloseErrorPolicy: """ What a lock does with an ``os.close`` failure after relinquishing descriptor ownership. .. versionadded:: 3.30.0 """ return self._close_error_policy def _close_released_fd(self, fd: int, *, default_suppresses: bool) -> None: # CPython never retries close() after EINTR because the descriptor number may already be reused, so neither does # this. close_error_policy decides the error's fate after the backend relinquishes descriptor ownership. try: os.close(fd) except OSError: if self._close_error_policy == "suppress" or (self._close_error_policy == "default" and default_suppresses): return raise @property def fallback_to_soft(self) -> bool: """ Whether a :class:`FileLock` falls back to :class:`SoftFileLock` when the filesystem lacks ``flock``. Only :class:`UnixFileLock` acts on it: when ``False`` an ``ENOSYS`` from ``flock`` propagates instead of switching to existence-lock semantics. .. versionadded:: 3.30.0 """ return self._fallback_to_soft @property def preserve_lock_file(self) -> bool: """ Whether filelock promises not to unlink the lock pathname on release. When ``True``, Windows skips its post-release unlink and Unix refuses the ``ENOSYS`` soft fallback. :class:`SoftFileLock` rejects ``True`` because unlinking its marker is how it releases. .. versionadded:: 3.30.0 """ return self._preserve_lock_file @property def on_acquired(self) -> Callable[[int], None] | None: """ The callback run with the borrowed lock descriptor once per physical acquisition, or ``None``. Native locks only. It runs after the native lock is held and backend initialization finished, before :meth:`~BaseFileLock.acquire` returns; a raise rolls back the acquisition. :class:`SoftFileLock` rejects it. .. versionadded:: 3.30.0 """ return self._on_acquired @property def lock_file(self) -> str: """Path to the lock file.""" return self._context.lock_file @property def timeout(self) -> float: """ The default timeout value, in seconds. .. versionadded:: 2.0.0 """ return self._context.timeout @timeout.setter def timeout(self, value: float | str) -> None: """ Change the default timeout value. :param value: the new value, in seconds """ self._context.timeout = float(value) @property def blocking(self) -> bool: """ Whether the locking is blocking or not. .. versionadded:: 3.14.0 """ return self._context.blocking @blocking.setter def blocking(self, value: bool) -> None: """ Change the default blocking value. :param value: the new value as bool """ self._context.blocking = value @property def poll_interval(self) -> float: """ The default polling interval, in seconds. .. versionadded:: 3.24.0 """ return self._context.poll_interval @poll_interval.setter def poll_interval(self, value: float) -> None: """ Change the default polling interval. :param value: the new value, in seconds """ self._context.poll_interval = value @property def lifetime(self) -> float | None: """ The soft marker age in seconds that permits expiry, or ``None`` to disable age-based expiry. A non-``None`` value permits a waiter to enter while the previous holder remains active, so it does not provide strict mutual exclusion. Native locks ignore the value with a warning. .. versionadded:: 3.24.0 """ return self._context.lifetime @lifetime.setter def lifetime(self, value: float | None) -> None: """ Change the legacy age-based expiry threshold. :param value: the new value in seconds, or ``None`` to disable expiration :raises ValueError: if *value* is negative or not finite :raises TypeError: if *value* is not ``None`` and not a real number """ self._context.lifetime = _resolve_lifetime(value, type(self), stacklevel=3) @property def mode(self) -> int: """The file permissions for the lockfile.""" return 0o644 if self._context.mode == _UNSET_FILE_MODE else self._context.mode @property def has_explicit_mode(self) -> bool: """Whether the file permissions were explicitly set.""" return self._context.mode != _UNSET_FILE_MODE def _open_mode(self) -> int: """Mode for ``os.open``: 0o666 when unset so umask and ACLs decide, otherwise the explicit mode.""" return 0o666 if self._context.mode == _UNSET_FILE_MODE else self._context.mode @property def is_locked(self) -> bool: """ A boolean indicating if the lock file is holding the lock currently. .. versionchanged:: 2.0.0 This was previously a method and is now a property. """ _ensure_current_process() return self._context.lock_file_fd is not None @property def lock_counter(self) -> int: """The number of times this lock has been acquired (but not yet released).""" _ensure_current_process() return self._context.lock_counter def __enter__(self) -> Self: """ Acquire the lock. :returns: the lock object """ self.acquire() return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Release the lock, reconciling a release failure with any body failure per :attr:`context_error_policy`.""" self._release_in_context(exc_value) def _release_in_context(self, body_error: BaseException | None) -> None: # Release from a context-manager exit. "chain" lets a release failure propagate with the body error already in # its __context__ (Python's default); "group" raises both as sibling leaves so neither one hides the other. try: self.release() except BaseException as release_error: if body_error is None or self._context_error_policy == "chain": raise _raise_body_and_release(body_error, release_error) def __del__(self) -> None: """Force-release so a dropped reference never leaks a held lock.""" if vars(self).get("_creator_pid") != os.getpid(): return # pragma: forked child # A finalizer must not raise. A release error during garbage collection would otherwise surface as an # unraisable-exception warning, attributed to whichever code triggered collection. The dropped lock still gets # best-effort cleanup; an explicit release() reports the same error to a caller who can act on it. with contextlib.suppress(Exception): self.release(force=True) def acquire( self, timeout: float | None = None, poll_interval: float | None = None, *, poll_intervall: float | None = None, blocking: bool | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AcquireReturnProxy: """ Try to acquire the file lock. :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~timeout` is and if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default :attr:`~poll_interval` :param poll_intervall: deprecated, kept for backwards compatibility, use ``poll_interval`` instead :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired. :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. :returns: a context object that will unlock the file when the context is exited :raises Timeout: if fails to acquire lock within the timeout period .. code-block:: python # You can use this method in the context manager (recommended) with lock.acquire(): pass # Or use an equivalent try-finally construct: lock.acquire() try: pass finally: lock.release() .. versionchanged:: 2.0.0 This method returns now a *proxy* object instead of *self*, so that it can be used in a with statement without side effects. """ self._raise_if_inherited() if timeout is None: timeout = self._context.timeout if blocking is None: blocking = self._context.blocking if poll_intervall is not None: msg = "use poll_interval instead of poll_intervall" warnings.warn(msg, DeprecationWarning, stacklevel=2) poll_interval = poll_intervall poll_interval = poll_interval if poll_interval is not None else self._context.poll_interval start_time = time.perf_counter() # Wait for admission before touching any state: a caller refused entry must leave the counter, the registry and # the descriptor exactly as it found them. with self._transition_admission( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ): # Bump the counter up front; _undo_acquire rolls it back if acquisition fails. self._context.lock_counter += 1 canonical = _canonical(self.lock_file) self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking) try: self._poll_until_acquired( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except BaseException: self._reconcile_failed_acquire(canonical) raise self._commit_acquire(canonical) return AcquireReturnProxy(lock=self) @contextlib.contextmanager def _transition_admission( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> Generator[None]: # One thread at a time drives the physical transition of a shared instance. A protocol that publishes several # files per owner leaves a half-built claim visible otherwise, and a second thread would read it as a holder. # Only such a backend opts in; a single-file backend and a thread-local context each need no gate. if not self._serialize_transitions or self._is_thread_local: yield return while not self._transition_lock.acquire(blocking=False): # pragma: needs hard-link if not blocking or (cancel_check is not None and cancel_check()): raise Timeout(self.lock_file) if timeout >= 0 and time.perf_counter() - start_time >= timeout: raise Timeout(self.lock_file) time.sleep(poll_interval) try: # pragma: needs hard-link yield finally: # pragma: needs hard-link self._transition_lock.release() def release(self, force: bool = False) -> None: # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility """ Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file itself may be deleted automatically, the behavior is platform-specific. :param force: If true, the lock counter is ignored and the lock is released in every case. """ # A shared instance releases under the same gate its acquisition ran through, so a thread entering the lock # never observes a partially torn-down owner. serialize = self._serialize_transitions and not self._is_thread_local with self._transition_lock if serialize else contextlib.nullcontext(): if self._creator_pid != os.getpid() or not self.is_locked: return if not force and self._context.lock_counter > 1: self._context.lock_counter -= 1 return lock_id, lock_filename = id(self), self.lock_file _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) try: self._release_with_fork_tracking() except BaseException: # A failure after the OS unlock (during close or unlink) still released the lock: the backend cleared # its descriptor, so commit the counter and registry to released even as the cleanup error propagates. # A failure that left the lock held keeps the counter so a later release can retry the OS unlock. if not self.is_locked: self._commit_release() raise self._commit_release() _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) def _raise_if_inherited(self) -> None: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"{type(self).__name__} on {self.lock_file} was inherited across fork; construct a new instance" raise RuntimeError(msg) def _mark_descriptor_owned(self, fd: int, identity: tuple[int, int] | None = None) -> None: self._context.pending_lock_file_fd = None self._context.pending_lock_file_fd_identity = None self._context.lock_file_fd = fd self._context.lock_file_fd_identity = identity def _mark_descriptor_pending(self, fd: int, identity: tuple[int, int] | None = None) -> None: self._context.pending_lock_file_fd = fd self._context.pending_lock_file_fd_identity = identity def _mark_descriptor_released(self) -> None: self._context.pending_lock_file_fd = None self._context.pending_lock_file_fd_identity = None self._context.lock_file_fd = None self._context.lock_file_fd_identity = None def _reset_after_fork_in_child(self) -> None: # pragma: forked child # fork copies the lock in whatever state the parent's threads left it, so give the child an unheld one. self._transition_lock = RLock() self._context.owner_claim_paths = () self._context.claim_root = None self._context.lock_file_fd = None self._context.lock_file_fd_token = None self._context.lock_file_fd_identity = None self._context.pending_lock_file_fd = None self._context.pending_lock_file_fd_identity = None self._context.lock_counter = 0 self._context.lock_file_key = None def _descriptors_for_fork(self) -> tuple[tuple[int, tuple[int, int] | None], ...]: # pragma: needs fork descriptors: list[tuple[int, tuple[int, int] | None]] = [] if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: descriptors.append((self._context.lock_file_fd, self._context.lock_file_fd_identity)) if self._context.pending_lock_file_fd is not None: descriptors.append((self._context.pending_lock_file_fd, self._context.pending_lock_file_fd_identity)) return tuple(descriptors) def _raise_if_would_deadlock(self, canonical: str, *, timeout: float, blocking: bool) -> None: """ Fail fast when a *different* live instance already holds this path in the current deadlock scope. Only the first, indefinitely-blocking acquire can self-deadlock this way: waiting in the OS primitive would block on a lock this flow already owns. A finite timeout or ``blocking=False`` keeps the normal Timeout path. """ would_block = self._context.lock_counter == 1 and not self.is_locked and timeout < 0 and blocking if would_block and _registry.held.get(self._registry_key(canonical)) not in {None, id(self)}: self._context.lock_counter -= 1 msg = ( f"Deadlock: lock '{self.lock_file}' is already held by a different {self._deadlock_holder_desc}. " f"Use is_singleton=True to enable reentrant locking across instances." ) raise RuntimeError(msg) def _registry_key(self, canonical: str) -> Hashable: return canonical if (scope := self._deadlock_scope()) is None else (scope, canonical) @staticmethod def _deadlock_scope() -> Hashable | None: """ Execution unit whose own hold would deadlock a new acquire. ``None`` scopes holders to the thread, which the thread-local registry already separates. Async locks override it with the running task, since one event loop thread runs many tasks and only a reacquire from the *same* task can self-deadlock. """ return None def _poll_until_acquired( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> None: lock_id = id(self) lock_filename = self.lock_file attempt = 0 while True: self._raise_if_inherited() if not self.is_locked: self._try_break_expired_lock() _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) self._acquire_with_fork_tracking() self._raise_if_inherited() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) return if self._check_give_up( blocking=blocking, cancel_check=cancel_check, timeout=timeout, start_time=start_time, ): raise Timeout(lock_filename) attempt += 1 delay = self._poll_delay(poll_interval, attempt) msg = "Lock %s not acquired on %s, waiting %s seconds ..." _LOGGER.debug(msg, lock_id, lock_filename, delay) time.sleep(delay) def _poll_delay(self, poll_interval: float, attempt: int) -> float: # A single-file lock retries on a fixed cadence. A backend that publishes several files per acquisition sets a # cap, and then contending processes back off across a jittered, exponentially widening window instead of # colliding on every poll; poll_interval stays the floor so a lone waiter is still responsive. if not self._poll_backoff_cap: return poll_interval # Cap the exponent before doubling: under heavy contention attempt reaches the thousands, and 2**attempt would # overflow the float multiply long before the window itself stops growing past the cap. window = min( self._poll_backoff_cap, poll_interval * 2 ** min(attempt, _MAX_BACKOFF_EXPONENT) ) # pragma: needs hard-link return max(poll_interval, secrets.randbelow(int(window * 1_000_000) + 1) / 1_000_000) # pragma: needs hard-link def _reconcile_failed_acquire(self, canonical: str) -> None: # An acquire that raised while still holding the native lock (a hook that failed and whose rollback could not # release) must keep the registry entry so a later release can retry the OS unlock; otherwise roll the counter # back. is_locked was already reconciled by whichever release ran. if self.is_locked: self._commit_acquire(canonical) else: self._undo_acquire() def _invoke_on_acquired(self) -> None: # The wrapper runs in the backend executor for async locks, preserving the callback's documented thread. if self._on_acquired is None or self._context.lock_counter != 1: return try: self._on_acquired(cast("int", self._context.lock_file_fd)) except BaseException as callback_error: # arbitrary caller code; roll back on any failure callback_context = callback_error.__context__ try: self._release_with_fork_tracking() except BaseException as release_error: # ruff:ignore[blind-except] # both errors surface via the group below _raise_body_and_release(callback_error, release_error) callback_error.__context__ = callback_context raise def _acquire_with_fork_tracking(self) -> None: with _fork_transition(self): try: self._acquire() except BaseException as acquisition_error: self._rollback_failed_acquire(acquisition_error) raise try: self._register_context_descriptor() except BaseException as registration_error: # pragma: needs fork self._rollback_failed_registration(registration_error) raise if self.is_locked: self._invoke_on_acquired() def _rollback_failed_acquire(self, acquisition_error: BaseException) -> None: if not self.is_locked: return registration_error: BaseException | None = None tracking_error: BaseException | None = None try: self._register_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # preserve registration and acquisition failures registration_error = error try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: self._release_with_fork_tracking() except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and acquisition failures _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error, rollback_error, ) if registration_error is not None: # pragma: needs fork _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error ) def _rollback_failed_registration(self, registration_error: BaseException) -> None: # pragma: needs fork tracking_error: BaseException | None = None try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: self._release_with_fork_tracking() except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and registration failures _raise_cleanup_errors( "descriptor registration cleanup failed", registration_error, tracking_error, rollback_error ) if tracking_error is not None: # pragma: no cover - requires failed in-memory fallback _raise_cleanup_errors("descriptor registration cleanup failed", registration_error, tracking_error) def _release_with_fork_tracking(self) -> None: with _fork_transition(self): try: self._release() finally: self._unregister_released_descriptor() def _register_context_descriptor(self) -> None: if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: self._context.lock_file_fd_token = _register_owned_descriptor( self._context.lock_file_fd, self._context.lock_file_fd_identity, ) def _register_unverified_context_descriptor(self) -> None: # The rollback only reaches here still holding a descriptor it never managed to register. if self._context.lock_file_fd is not None and self._context.lock_file_fd_token is None: # pragma: no branch self._context.lock_file_fd_token = _register_unverified_owned_descriptor(self._context.lock_file_fd) def _unregister_released_descriptor(self) -> None: if self._context.lock_file_fd is None: if (token := self._context.lock_file_fd_token) is not None: # pragma: needs fork _unregister_owned_descriptor(token) self._context.lock_file_fd_token = None self._context.lock_file_fd_identity = None def _undo_acquire(self) -> None: """Roll back the counter after a failed acquire, dropping the registry entry once nothing holds the path.""" self._context.lock_counter = max(0, self._context.lock_counter - 1) if self._context.lock_counter == 0: self._drop_registry_entry() def _commit_acquire(self, canonical: str) -> None: """Record this instance as the holder once the first acquire succeeds, so peers can detect the deadlock.""" if self._context.lock_counter == 1: key = self._registry_key(canonical) # The holder scope is resolved once at commit so a later release from another flow drops the right entry. self._context.lock_file_key = key _registry.held[key] = id(self) def _drop_registry_entry(self) -> None: """Forget the key owned by this hold without resolving a mutable path again.""" key = self._context.lock_file_key self._context.lock_file_key = None if key is not None: _registry.held.pop(key, None) def _commit_release(self) -> None: """Record the lock as fully released: reset the recursion counter and drop the deadlock-registry entry.""" self._context.lock_counter = 0 self._drop_registry_entry() 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_file) if time.time() - st.st_mtime < lifetime: return break_lock_file(self.lock_file, st.st_mtime, st.st_ino) def _check_give_up( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, start_time: float, ) -> bool: lock_id, lock_filename = id(self), self.lock_file if blocking is False: _LOGGER.debug("Failed to immediately acquire lock %s on %s", lock_id, lock_filename) return True if cancel_check is not None and cancel_check(): _LOGGER.debug("Cancellation requested for lock %s on %s", lock_id, lock_filename) return True if 0 <= timeout < time.perf_counter() - start_time: _LOGGER.debug("Timeout on acquiring lock %s on %s", lock_id, lock_filename) return True return False @abstractmethod def _acquire(self) -> None: """If the file lock could be acquired, self._context.lock_file_fd holds the file descriptor of the lock file.""" raise NotImplementedError @abstractmethod def _release(self) -> None: """Releases the lock and sets self._context.lock_file_fd to None.""" raise NotImplementedError # acquire() returns this wrapper instead of self so entering the with-statement does not call __enter__ a second # time; returning self would re-acquire the lock in BaseFileLock.__enter__ without a matching release (issue #37). class AcquireReturnProxy: """A context-aware object that will release the lock file when exiting.""" def __init__(self, lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock) -> None: self.lock: BaseFileLock | ReadWriteLock | SoftReadWriteLock = lock def __enter__(self) -> BaseFileLock | ReadWriteLock | SoftReadWriteLock: return self.lock def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: if isinstance(self.lock, BaseFileLock): self.lock._release_in_context(exc_value) # ruff:ignore[private-member-access] # forwards __exit__ to the owned lock's context release else: # a reader/writer lock does not carry a context_error_policy self.lock.release() @dataclass class FileLockContext: """Holds the context for a ``BaseFileLock`` object.""" # A separate class so ThreadLocalFileContext can make the whole context thread-local. lock_file: str timeout: float mode: int blocking: bool poll_interval: float #: The lock lifetime in seconds; ``None`` means the lock never expires. lifetime: float | None = None #: File descriptor from os.open for the lock file; not None while the lock is held. lock_file_fd: int | None = None #: Registry token for the descriptor owned by this thread's context. lock_file_fd_token: int | None = None #: Identity captured by a backend that already inspected the descriptor. lock_file_fd_identity: tuple[int, int] | None = None #: Descriptor opened by a backend but not yet committed as the held lock. pending_lock_file_fd: int | None = None #: Identity captured for a descriptor whose acquisition has not committed. pending_lock_file_fd_identity: tuple[int, int] | None = None #: Depth of nested acquisitions; the lock is released only when it returns to 0. lock_counter: int = 0 #: Canonical registry key captured when the first physical acquisition commits. lock_file_key: Hashable | None = None #: Claim pathnames this owner published, removed by name on release so no holder ever unlinks a peer's claim. owner_claim_paths: tuple[str, ...] = () #: Canonical lock path resolved when an acquisition starts. A waiter polling a relative path must keep publishing #: into the directory it started in, even when another thread changes the working directory mid-wait. claim_root: str | None = None class ThreadLocalFileContext(FileLockContext, local): """A thread local version of the ``FileLockContext`` class.""" @dataclass(frozen=True) class _OwnedDescriptor: fd: int creator_pid: int device: int | None inode: int | None class _ForkTransitionContext(local): depth: int = 0 class _ForkState: def __init__(self) -> None: self.gate = Condition(RLock()) self.registry_lock = RLock() self.parameter_models_lock = RLock() self.transition_context = _ForkTransitionContext() self.transitions: dict[int, dict[int, _ForkDescriptorOwner | None]] = {} self.active_transitions = 0 self.admission_closed = False self.fork_owner_depths: dict[int, int] = {} self.pinned_objects: dict[int, list[tuple[_ForkResettable, ...]]] = {} self.pinned_classes: dict[int, list[tuple[_ForkResettableClass, ...]]] = {} self.provisional_descriptor_tokens: dict[int, list[tuple[int, ...]]] = {} self.pid = os.getpid() def reset_synchronization(self) -> None: # pragma: forked child self.gate = Condition(RLock()) self.registry_lock = RLock() self.parameter_models_lock = RLock() self.transition_context = _ForkTransitionContext() self.transitions = {} self.active_transitions = 0 self.admission_closed = False self.fork_owner_depths = {} self.pinned_objects = {} self.pinned_classes = {} self.provisional_descriptor_tokens = {} _FORK_OBJECTS: Final[WeakValueDictionary[int, _ForkResettable]] = WeakValueDictionary() _FORK_CLASSES: Final[WeakValueDictionary[int, _ForkResettableClass]] = WeakValueDictionary() _OWNED_DESCRIPTORS: Final[dict[int, _OwnedDescriptor]] = {} _DESCRIPTOR_TOKENS: Final[count[int]] = count() _TRANSITION_TOKENS: Final[count[int]] = count() _FORK_STATE: Final = _ForkState() _FORK_AUDIT_EVENTS: Final[frozenset[str]] = frozenset({"os.fork", "os.forkpty"}) def _register_fork_hooks() -> None: if _REGISTER_AT_FORK is None: return # pragma: lacks fork sys.addaudithook(_audit_fork_safety) # pragma: needs fork _REGISTER_AT_FORK( # pragma: needs fork before=_pin_fork_objects, after_in_parent=_resume_parent_after_fork, after_in_child=_reset_child_after_fork, ) @contextmanager def _fork_transition(descriptor_owner: _ForkDescriptorOwner | None = None) -> Generator[None]: if not _HAS_REGISTER_AT_FORK: yield # pragma: lacks fork return # pragma: lacks fork _ensure_current_process() # pragma: needs fork creator_pid = os.getpid() # pragma: needs fork token = _enter_fork_transition(descriptor_owner) # pragma: needs fork try: # pragma: needs fork yield finally: if os.getpid() == creator_pid: # pragma: needs fork _leave_fork_transition(token) def _register_fork_object(instance: _ForkResettable) -> None: if not _HAS_REGISTER_AT_FORK: return # pragma: lacks fork with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork _FORK_OBJECTS[id(instance)] = instance _refresh_owner_pins() def _register_fork_class(cls: _ForkResettableClass) -> None: if not _HAS_REGISTER_AT_FORK: return # pragma: lacks fork with _fork_transition(), _FORK_STATE.registry_lock: # pragma: needs fork _FORK_CLASSES[id(cls)] = cls _refresh_owner_pins() def _register_owned_descriptor(fd: int, identity: tuple[int, int] | None = None) -> int | None: if not _HAS_REGISTER_AT_FORK: return None # pragma: lacks fork with _fork_transition(): # pragma: needs fork if identity is None: stat_result = os.fstat(fd) identity = stat_result.st_dev, stat_result.st_ino return _record_owned_descriptor(fd, identity) def _register_unverified_owned_descriptor(fd: int) -> int | None: if not _HAS_REGISTER_AT_FORK: return None # pragma: lacks fork with _fork_transition(): # pragma: needs fork return _record_owned_descriptor(fd, None) def _record_owned_descriptor(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork with _FORK_STATE.registry_lock: token = next(_DESCRIPTOR_TOKENS) _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( fd=fd, creator_pid=os.getpid(), device=None if identity is None else identity[0], inode=None if identity is None else identity[1], ) return token def _unregister_owned_descriptor(token: int) -> None: # pragma: needs fork with _fork_transition(), _FORK_STATE.registry_lock: _OWNED_DESCRIPTORS.pop(token, None) def _pin_fork_objects() -> None: # pragma: needs fork _ensure_current_process() thread_id = get_ident() with _FORK_STATE.gate: _FORK_STATE.fork_owner_depths[thread_id] = _FORK_STATE.fork_owner_depths.get(thread_id, 0) + 1 _FORK_STATE.admission_closed = True while _FORK_STATE.active_transitions > len(_FORK_STATE.transitions.get(thread_id, ())): _FORK_STATE.gate.wait() transition_owners = tuple(_FORK_STATE.transitions.get(thread_id, {}).values()) _verify_unverified_descriptors() owners = {id(owner): owner for owner in transition_owners if owner is not None} provisional_descriptor_tokens = tuple( starmap( _snapshot_descriptor_for_fork, (descriptor for owner in owners.values() for descriptor in owner._descriptors_for_fork()), # ruff:ignore[private-member-access] # snapshots each owner's own fork descriptors ) ) with _FORK_STATE.registry_lock: _FORK_STATE.provisional_descriptor_tokens.setdefault(thread_id, []).append(provisional_descriptor_tokens) _FORK_STATE.pinned_objects.setdefault(thread_id, []).append(tuple(_FORK_OBJECTS.values())) _FORK_STATE.pinned_classes.setdefault(thread_id, []).append(tuple(_FORK_CLASSES.values())) def _resume_parent_after_fork() -> None: # pragma: needs fork thread_id = get_ident() with _FORK_STATE.registry_lock: for token in _FORK_STATE.provisional_descriptor_tokens[thread_id].pop(): _OWNED_DESCRIPTORS.pop(token, None) _FORK_STATE.pinned_objects[thread_id].pop() _FORK_STATE.pinned_classes[thread_id].pop() if not _FORK_STATE.provisional_descriptor_tokens[thread_id]: # pragma: needs fork del _FORK_STATE.provisional_descriptor_tokens[thread_id] del _FORK_STATE.pinned_objects[thread_id] del _FORK_STATE.pinned_classes[thread_id] with _FORK_STATE.gate: if _FORK_STATE.fork_owner_depths[thread_id] == 1: del _FORK_STATE.fork_owner_depths[thread_id] else: # pragma: no cover - earlier at-fork callbacks may deadlock first _FORK_STATE.fork_owner_depths[thread_id] -= 1 _FORK_STATE.admission_closed = bool(_FORK_STATE.fork_owner_depths) _FORK_STATE.gate.notify_all() def _ensure_current_process() -> None: _reset_child_after_fork() def _reset_child_after_fork() -> None: # pragma: forked child if (pid := os.getpid()) == _FORK_STATE.pid: return thread_id = get_ident() pinned_objects = _FORK_STATE.pinned_objects.get(thread_id, [()])[-1] pinned_classes = _FORK_STATE.pinned_classes.get(thread_id, [()])[-1] descriptors: list[_OwnedDescriptor] = [] for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): if descriptor.creator_pid != pid: descriptors.append(_OWNED_DESCRIPTORS.pop(token)) _FORK_STATE.reset_synchronization() _FORK_STATE.pid = pid _INIT_PARAMETER_MODELS.clear() _detach_child_state(descriptors, pinned_objects, pinned_classes) def _enter_fork_transition(descriptor_owner: _ForkDescriptorOwner | None) -> int: # pragma: needs fork thread_id = get_ident() with _FORK_STATE.gate: while ( _FORK_STATE.admission_closed and thread_id not in _FORK_STATE.fork_owner_depths and thread_id not in _FORK_STATE.transitions ): _FORK_STATE.gate.wait() # pragma: no cover - exercised in isolated interpreter token = next(_TRANSITION_TOKENS) _FORK_STATE.transitions.setdefault(thread_id, {})[token] = descriptor_owner _FORK_STATE.active_transitions += 1 _FORK_STATE.transition_context.depth += 1 return token def _leave_fork_transition(token: int) -> None: # pragma: needs fork _FORK_STATE.transition_context.depth -= 1 with _FORK_STATE.gate: thread_id = get_ident() del _FORK_STATE.transitions[thread_id][token] if not _FORK_STATE.transitions[thread_id]: del _FORK_STATE.transitions[thread_id] _FORK_STATE.active_transitions -= 1 _FORK_STATE.gate.notify_all() def _verify_unverified_descriptors() -> None: # pragma: needs fork with _FORK_STATE.registry_lock: for token, descriptor in tuple(_OWNED_DESCRIPTORS.items()): if descriptor.creator_pid != os.getpid() or descriptor.device is not None: continue try: stat_result = os.fstat(descriptor.fd) except OSError: continue _OWNED_DESCRIPTORS[token] = _OwnedDescriptor( fd=descriptor.fd, creator_pid=descriptor.creator_pid, device=stat_result.st_dev, inode=stat_result.st_ino, ) def _snapshot_descriptor_for_fork(fd: int, identity: tuple[int, int] | None) -> int: # pragma: needs fork if identity is None: # pragma: needs fork try: stat_result = os.fstat(fd) except OSError: pass else: identity = stat_result.st_dev, stat_result.st_ino return _record_owned_descriptor(fd, identity) def _detach_child_state( # pragma: forked child descriptors: list[_OwnedDescriptor], pinned_objects: tuple[_ForkResettable, ...], pinned_classes: tuple[_ForkResettableClass, ...], ) -> None: for descriptor in descriptors: if descriptor.device is None: continue try: stat_result = os.fstat(descriptor.fd) except OSError: continue if (stat_result.st_dev, stat_result.st_ino) != (descriptor.device, descriptor.inode): continue with contextlib.suppress(OSError): os.close(descriptor.fd) for instance in pinned_objects: instance._reset_after_fork_in_child() # ruff:ignore[private-member-access] # resets each pinned instance in the fork child for cls in pinned_classes: cls._reset_class_after_fork() _registry.held.clear() def _refresh_owner_pins() -> None: # pragma: needs fork with _FORK_STATE.gate: fork_owner_thread_ids = tuple(_FORK_STATE.fork_owner_depths) if get_ident() not in fork_owner_thread_ids: # pragma: no cover - at-fork callbacks disable tracing return objects = tuple(_FORK_OBJECTS.values()) classes = tuple(_FORK_CLASSES.values()) for thread_id in fork_owner_thread_ids: if object_snapshots := _FORK_STATE.pinned_objects.get(thread_id): # pragma: needs fork object_snapshots[:] = [objects] * len(object_snapshots) _FORK_STATE.pinned_classes[thread_id][:] = [classes] * len(object_snapshots) # Audit events carry unrelated interpreter-owned values; object is the sole accurate common element type. def _audit_fork_safety( # pragma: no cover - CPython disables tracing while Python audit hooks run event: str, _args: tuple[object, ...] ) -> None: if event in _FORK_AUDIT_EVENTS: if _FORK_STATE.transition_context.depth or _FORK_STATE.fork_owner_depths: msg = f"{event} is unsafe while filelock is changing descriptor ownership" raise RuntimeError(msg) elif event == "_posixsubprocess.fork_exec" and _FORK_STATE.transition_context.depth: msg = "fork_exec is unsafe while filelock is changing descriptor ownership" raise RuntimeError(msg) _register_fork_hooks() __all__ = [ "_UNSET_FILE_MODE", "AcquireReturnProxy", "BaseFileLock", "CloseErrorPolicy", "ContextErrorPolicy", "FileLockContext", "FileLockMeta", "LockOptions", "_append_exception_context", "_canonical", "_ensure_current_process", "_fork_transition", "_grouped_errors", "_raise_body_and_release", "_raise_chained_errors", "_raise_cleanup_errors", "_raise_grouped_errors", "_register_fork_class", "_register_fork_object", "_register_owned_descriptor", "_unregister_owned_descriptor", ] filelock-3.32.2/src/filelock/_async.py0000644000000000000000000001373713615410400014531 0ustar00"""Separate caller cancellation from backend task and executor-future results.""" from __future__ import annotations import asyncio import contextlib import time from concurrent.futures import Future as ConcurrentFuture from dataclasses import dataclass from threading import Lock from typing import TYPE_CHECKING, Final, Generic, NoReturn, TypeVar, cast from ._api import _append_exception_context, _raise_chained_errors if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Callable _T = TypeVar("_T") class _AsyncTransitionUnavailableError(Exception): pass @dataclass(frozen=True) class _BackendOutcome(Generic[_T]): value: _T | None = None error: BaseException | None = None class _AsyncTransitionGate: def __init__(self) -> None: self._tail_lock: Final[Lock] = Lock() self._tail: ConcurrentFuture[None] | None = None @contextlib.asynccontextmanager async def hold(self) -> AsyncIterator[None]: ticket: ConcurrentFuture[None] = ConcurrentFuture() with self._tail_lock: predecessor = self._tail self._tail = ticket if predecessor is not None: try: await _wait_until_done(asyncio.wrap_future(predecessor)) except asyncio.CancelledError: predecessor.add_done_callback(lambda _predecessor: self._leave(ticket)) raise try: yield finally: self._leave(ticket) @contextlib.asynccontextmanager async def hold_for_acquire( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, deadline: float | None, poll_interval: float, ) -> AsyncIterator[None]: ticket: ConcurrentFuture[None] = ConcurrentFuture() with self._tail_lock: predecessor = self._tail self._tail = ticket if predecessor is not None and not predecessor.done(): try: await self._wait_for_predecessor( predecessor, blocking=blocking, cancel_check=cancel_check, deadline=deadline, poll_interval=poll_interval, ) except BaseException: predecessor.add_done_callback(lambda _predecessor: self._leave(ticket)) raise try: yield finally: self._leave(ticket) @staticmethod async def _wait_for_predecessor( predecessor: ConcurrentFuture[None], *, blocking: bool, cancel_check: Callable[[], bool] | None, deadline: float | None, poll_interval: float, ) -> None: if not blocking: raise _AsyncTransitionUnavailableError waiter = asyncio.wrap_future(predecessor) while not predecessor.done(): if cancel_check is not None and cancel_check(): raise _AsyncTransitionUnavailableError if deadline is not None: if (remaining := deadline - time.perf_counter()) <= 0: raise _AsyncTransitionUnavailableError wait_interval = min(poll_interval, remaining) if cancel_check is not None else remaining else: wait_interval = poll_interval if cancel_check is not None else None await asyncio.wait((waiter,), timeout=wait_interval) def _leave(self, ticket: ConcurrentFuture[None]) -> None: with self._tail_lock: if self._tail is ticket: self._tail = None ticket.set_result(None) async def _drain_future(future: asyncio.Future[_BackendOutcome[_T]]) -> _T: while not future.done(): with contextlib.suppress(asyncio.CancelledError): await _wait_until_done(future) return _future_result(future) async def _wait_until_done(future: asyncio.Future[_T]) -> None: if not future.done(): await asyncio.wait((future,)) def _future_result(future: asyncio.Future[_BackendOutcome[_T]]) -> _T: outcome = future.result() if (error := outcome.error) is None: return cast("_T", outcome.value) context = error.__context__ try: raise error # ruff:ignore[raise-within-try] # the handler restores context changed across the async boundary except BaseException: error.__context__ = context raise def _capture_call(func: Callable[[], _T]) -> _BackendOutcome[_T]: try: return _BackendOutcome(value=func()) except BaseException as error: # ruff:ignore[blind-except] # backend control-flow exceptions are operation results return _BackendOutcome(error=error) def _raise_cancelled_error(cancellation: asyncio.CancelledError, error: BaseException) -> NoReturn: # A reconciliation step failed while unwinding a cancellation, so keep both exception chains. Splice the error's # existing context onto the cancellation, then make the cancellation the error's context, so both the failure and # the cancellation that triggered it survive. Shared by the async wrappers so cancellations report the same way. if (context := error.__context__) is not None and context is not cancellation: if (cancellation_context := cancellation.__context__) is not None: _append_exception_context(context, cancellation_context) cancellation.__context__ = context error.__context__ = cancellation _raise_chained_errors(error) async def _capture_awaitable(awaitable: Awaitable[_T]) -> _BackendOutcome[_T]: try: return _BackendOutcome(value=await awaitable) except BaseException as error: # ruff:ignore[blind-except] # backend cancellation must remain distinct from caller cancellation return _BackendOutcome(error=error) __all__ = [ "_AsyncTransitionGate", "_AsyncTransitionUnavailableError", "_BackendOutcome", "_capture_awaitable", "_capture_call", "_drain_future", "_future_result", "_raise_cancelled_error", "_wait_until_done", ] filelock-3.32.2/src/filelock/_async_read_write.py0000644000000000000000000003201513615410400016724 0ustar00"""Async wrapper around :class:`ReadWriteLock` for use with ``asyncio``.""" from __future__ import annotations import asyncio import functools import os import sqlite3 from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from typing import TYPE_CHECKING, ParamSpec, TypeVar from ._api import ( _append_exception_context, _ensure_current_process, _fork_transition, _register_fork_object, ) from ._async import ( _BackendOutcome, _capture_call, _drain_future, _future_result, _raise_cancelled_error, _wait_until_done, ) from ._read_write import ReadWriteLock if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable from concurrent import futures from types import TracebackType from ._api import AcquireReturnProxy _P = ParamSpec("_P") _R = TypeVar("_R") class AsyncReadWriteLock: """ Async wrapper around :class:`ReadWriteLock` for use in ``asyncio`` applications. This wrapper dispatches every blocking SQLite operation to a thread pool via ``loop.run_in_executor()`` because Python's :mod:`sqlite3` module has no async API. It delegates reentrancy, upgrade/downgrade rules, and singleton behavior to the underlying :class:`ReadWriteLock`. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :param is_singleton: if ``True``, reuse existing :class:`ReadWriteLock` instances for the same resolved path :param loop: event loop for ``run_in_executor``; ``None`` uses the running loop :param executor: executor for ``run_in_executor``. When ``None`` this lock creates and owns a dedicated single-thread executor so every operation runs on the same thread (SQLite affinity requires this) and shuts it down in :meth:`close`. This lock uses a caller-supplied executor as-is and never shuts it down, so after passing no executor call :meth:`close` to release the owned one. .. versionadded:: 3.21.0 """ def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, loop: asyncio.AbstractEventLoop | None = None, executor: futures.Executor | None = None, ) -> None: creator_pid = os.getpid() self._creator_pid = creator_pid self._fork_invalidated = False self._closed = False _register_fork_object(self) with _fork_transition(): self._lock = ReadWriteLock(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) self._loop = loop self._owns_executor = executor is None self._executor = executor or ThreadPoolExecutor(max_workers=1) if os.getpid() != creator_pid: # pragma: forked child msg = "AsyncReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) @property def lock_file(self) -> str: """The path to the lock file.""" return self._lock.lock_file @property def timeout(self) -> float: """The default timeout.""" return self._lock.timeout @property def blocking(self) -> bool: """Whether blocking is enabled by default.""" return self._lock.blocking @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop (or ``None`` for the running loop).""" return self._loop @property def executor(self) -> futures.Executor: """The executor used for ``run_in_executor`` (a dedicated single-thread one if none was supplied).""" return self._executor @asynccontextmanager async def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ if timeout is None: timeout = self._lock.timeout if blocking is None: blocking = self._lock.blocking await self.acquire_read(timeout, blocking=blocking) body_error: BaseException | None = None try: yield except BaseException as error: body_error = error raise finally: await self._release_in_context(body_error) @asynccontextmanager async def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ if timeout is None: timeout = self._lock.timeout if blocking is None: blocking = self._lock.blocking await self.acquire_write(timeout, blocking=blocking) body_error: BaseException | None = None try: yield except BaseException as error: body_error = error raise finally: await self._release_in_context(body_error) async def _release_in_context(self, body_error: BaseException | None) -> None: try: await self.release() except BaseException as release_error: if body_error is not None: _append_exception_context(release_error, body_error) raise async def acquire_read(self, timeout: float = -1, *, blocking: bool = True) -> AsyncAcquireReadWriteReturnProxy: """ Acquire a shared read lock. See :meth:`ReadWriteLock.acquire_read` for full semantics. :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: a proxy that can be used as an async context manager to release the lock :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self._raise_if_unusable() await self._run_acquire(functools.partial(self._lock.acquire_read, timeout, blocking=blocking)) return AsyncAcquireReadWriteReturnProxy(lock=self) async def acquire_write(self, timeout: float = -1, *, blocking: bool = True) -> AsyncAcquireReadWriteReturnProxy: """ Acquire an exclusive write lock. See :meth:`ReadWriteLock.acquire_write` for full semantics. :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: a proxy that can be used as an async context manager to release the lock :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self._raise_if_unusable() await self._run_acquire(functools.partial(self._lock.acquire_write, timeout, blocking=blocking)) return AsyncAcquireReadWriteReturnProxy(lock=self) async def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. See :meth:`ReadWriteLock.release` for full semantics. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ _ensure_current_process() if self._inherited: # pragma: needs fork return await self._run(self._lock.release, force=force) async def close(self) -> None: """ Release the lock (if held) and close the underlying SQLite connection. After calling this method, the lock instance is no longer usable. """ _ensure_current_process() if self._inherited: # pragma: needs fork return if self._closed: return close_future = self._submit(self._lock.close) try: await _wait_until_done(close_future) except asyncio.CancelledError as cancellation: try: await _drain_future(close_future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) self._closed = True self._shutdown_owned_executor() raise _future_result(close_future) self._closed = True # Wait for the worker to exit rather than letting it drain in the background: a caller that forks right # after closing deserves a single-threaded process, and os.fork warns about any surviving thread. if self._owns_executor: await asyncio.to_thread(functools.partial(self._executor.shutdown, wait=True)) async def _run_acquire(self, acquire: Callable[[], AcquireReturnProxy]) -> None: acquire_future = self._submit(acquire) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: try: await _drain_future(acquire_future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) try: await _drain_future(self._submit(self._lock.release)) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise _future_result(acquire_future) async def _run(self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs) -> _R: future = self._submit(func, *args, **kwargs) try: await _wait_until_done(future) except asyncio.CancelledError as cancellation: try: await _drain_future(future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise return _future_result(future) def _submit( self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs ) -> asyncio.Future[_BackendOutcome[_R]]: return (self._loop or asyncio.get_running_loop()).run_in_executor( self._executor, _capture_call, functools.partial(func, *args, **kwargs), ) def _shutdown_owned_executor(self) -> None: if self._owns_executor: self._executor.shutdown(wait=False) @property def _inherited(self) -> bool: return self._fork_invalidated or os.getpid() != self._creator_pid def _raise_if_unusable(self) -> None: _ensure_current_process() if self._inherited: # pragma: needs fork msg = f"AsyncReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) if self._closed: msg = "Cannot operate on a closed database." raise sqlite3.ProgrammingError(msg) def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._fork_invalidated = True def __del__(self) -> None: # Safety net when close() was never called: shut down the executor we own so its worker thread does not # outlive the lock. shutdown(wait=False) never blocks. if os.getpid() == getattr(self, "_creator_pid", None) and getattr(self, "_owns_executor", False): self._executor.shutdown(wait=False) class AsyncAcquireReadWriteReturnProxy: """Context-aware object that releases the async read/write lock on exit.""" def __init__(self, lock: AsyncReadWriteLock) -> None: self.lock = lock async def __aenter__(self) -> AsyncReadWriteLock: return self.lock async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock.release() __all__ = [ "AsyncAcquireReadWriteReturnProxy", "AsyncReadWriteLock", ] filelock-3.32.2/src/filelock/_descriptor.py0000644000000000000000000000564513615410400015571 0ustar00"""A minimal native lock over a caller-owned file descriptor, contending with :class:`FileLock` on the same file.""" from __future__ import annotations import sys import time from math import isfinite from typing import Final if sys.platform == "win32": # pragma: win32 cover from ._windows import _lock_fd_nonblocking, _unlock_fd else: # pragma: win32 no cover from ._unix import _lock_fd_nonblocking, _unlock_fd def lock_descriptor(fd: int, *, blocking: bool = True, poll_interval: float = 0.05) -> bool: """ Take the native OS lock on *fd*, a file descriptor the caller opened and owns. This is the same one-byte exclusive lock :class:`FileLock` uses, so a descriptor lock and a path lock on the same file contend with each other. Unlike :class:`FileLock` it adds no path handling: it never opens, truncates, closes, unlinks, chmods, canonicalizes, or falls back. The caller owns *fd* before, during, and after the call, and must close it. On Windows *fd* must be a synchronous descriptor (its handle not opened with ``FILE_FLAG_OVERLAPPED``). For timeout, reentrancy, singleton, lifetime, or stale-break behavior, use :class:`FileLock`. There is no async wrapper. Run this in an executor, or drive ``blocking=False`` from your own polling loop. :param fd: an open file descriptor the caller owns. :param blocking: when ``True`` (default), retry the nonblocking attempt every *poll_interval* seconds until it succeeds; when ``False``, make one attempt. :param poll_interval: finite, positive seconds between attempts while blocking; ignored when *blocking* is ``False``. :returns: ``True`` once the lock is held, or ``False`` on contention when ``blocking`` is ``False``. :raises OSError: for a permanent native failure, such as an invalid descriptor, or with ``errno.ENOSYS`` when the Python build lacks the native locking primitive. The descriptor is left open. :raises ValueError: if a blocking call receives a non-finite or non-positive *poll_interval*. .. versionadded:: 3.30.0 """ if not blocking: return _lock_fd_nonblocking(fd) if not isfinite(poll_interval) or poll_interval <= 0: msg: Final[str] = f"poll_interval must be finite and greater than 0, got {poll_interval}" raise ValueError(msg) while not _lock_fd_nonblocking(fd): time.sleep(poll_interval) return True def unlock_descriptor(fd: int) -> None: """ Release the native OS lock on *fd* without touching the descriptor. :param fd: the descriptor a prior :func:`lock_descriptor` locked; the caller still owns and must close it. :raises OSError: if the native unlock fails, including ``errno.ENOSYS`` when the Python build lacks the native locking primitive; the caller may retry on the same descriptor. .. versionadded:: 3.30.0 """ _unlock_fd(fd) __all__ = [ "lock_descriptor", "unlock_descriptor", ] filelock-3.32.2/src/filelock/_error.py0000644000000000000000000000521313615410400014533 0ustar00from __future__ import annotations class Timeout(TimeoutError): # ruff:ignore[error-suffix-on-exception-name] # public exception name; renaming breaks the API """Raised when the lock could not be acquired in *timeout* seconds.""" def __init__(self, lock_file: str) -> None: super().__init__() self._lock_file = lock_file def __reduce__(self) -> tuple[type[Timeout], tuple[str]]: # __init__ needs lock_file, so pickle must restore it as a constructor arg return self.__class__, (self._lock_file,) def __str__(self) -> str: # pragma: needs hard-link return f"The file lock '{self._lock_file}' could not be acquired." def __repr__(self) -> str: return f"{self.__class__.__name__}({self.lock_file!r})" @property def lock_file(self) -> str: """The path of the file lock.""" return self._lock_file class SoftFileLockLifetimeWarning(DeprecationWarning): """The configured soft-lock lifetime permits overlapping live holders after expiry.""" class LeaseSettingsMismatch(ValueError): # ruff:ignore[error-suffix-on-exception-name] # public exception name; renaming breaks the API """A lease contender disagrees with the published claim about how long the lease lasts.""" class SoftFileLockProtocolError(OSError): """Raised when strict soft-lock state cannot be interpreted without risking overlap.""" def __init__(self, lock_file: str, claim_name: str | None, reason: str) -> None: self._lock_file = lock_file self._claim_name = claim_name self._reason = reason super().__init__(self.__str__()) def __reduce__( self, ) -> tuple[type[SoftFileLockProtocolError], tuple[str, str | None, str]]: # pragma: needs hard-link return self.__class__, (self._lock_file, self._claim_name, self._reason) def __str__(self) -> str: location = self._lock_file if self._claim_name is None else f"{self._lock_file}: claim {self._claim_name!r}" return f"Invalid strict soft-lock state at {location}: {self._reason}" @property def lock_file(self) -> str: # pragma: needs hard-link """The requested lock path.""" return self._lock_file @property def claim_name(self) -> str | None: # pragma: needs hard-link """The claim that caused the error, if scanning identified one.""" return self._claim_name @property def reason(self) -> str: # pragma: needs hard-link """The protocol validation failure.""" return self._reason __all__ = [ "LeaseSettingsMismatch", "SoftFileLockLifetimeWarning", "SoftFileLockProtocolError", "Timeout", ] filelock-3.32.2/src/filelock/_identity.py0000644000000000000000000002033713615410400015237 0ustar00from __future__ import annotations import os import socket import sys from errno import EPERM, ESRCH from pathlib import Path from typing import Final def host_name() -> str: """The hostname recorded alongside an owner, so a marker written on another machine is never probed here.""" return socket.gethostname() def owner_is_stale(pid: int, hostname: str, start_token: int | None) -> bool: """ Whether the recorded owner is provably gone, so reclaiming its marker cannot detach a live holder. Fail closed: return ``True`` only when this process can prove the exact recorded owner is dead. A marker from another host cannot be probed; a live PID whose start token still matches, or whose token cannot be read, is the holder or is indistinguishable from it; a live PID whose start token differs is a recycled PID, so the process that wrote the marker is gone. PostgreSQL, Qt ``QLockFile`` and Mercurial all break a stale lock only on proof of death and treat an unreadable or foreign owner as still holding. """ if hostname != host_name(): return False if not process_alive(pid): return True if start_token is None: return False current = process_start_token(pid) return current is not None and current != start_token if sys.platform == "win32": # pragma: win32 cover import ctypes from ctypes import wintypes _KERNEL32: Final[ctypes.WinDLL] = ctypes.WinDLL("kernel32", use_last_error=True) _KERNEL32.CloseHandle.argtypes = [wintypes.HANDLE] _KERNEL32.CloseHandle.restype = wintypes.BOOL _KERNEL32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] _KERNEL32.OpenProcess.restype = wintypes.HANDLE _KERNEL32.GetProcessTimes.argtypes = [ wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ] _KERNEL32.GetProcessTimes.restype = wintypes.BOOL _WIN_SYNCHRONIZE: Final[int] = 0x100000 _WIN_PROCESS_QUERY_LIMITED_INFORMATION: Final[int] = 0x1000 _WIN_ERROR_INVALID_PARAMETER: Final[int] = 87 _WIN_INHERIT_HANDLE: Final[bool] = False def process_alive(pid: int) -> bool: """Whether a process with this PID exists, treating an access denial as proof it does.""" handle = _KERNEL32.OpenProcess(_WIN_SYNCHRONIZE, _WIN_INHERIT_HANDLE, pid) if handle: _KERNEL32.CloseHandle(handle) return True return ctypes.get_last_error() != _WIN_ERROR_INVALID_PARAMETER def process_start_token(pid: int) -> int | None: """The process creation FILETIME as a 100ns tick count, or ``None`` when it cannot be read.""" handle = _KERNEL32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, _WIN_INHERIT_HANDLE, pid) if not handle: return None creation, exit_time, kernel_time, user_time = (wintypes.FILETIME() for _ in range(4)) try: if not _KERNEL32.GetProcessTimes( handle, ctypes.byref(creation), ctypes.byref(exit_time), ctypes.byref(kernel_time), ctypes.byref(user_time), ): return None # pragma: no cover # win32 GetProcessTimes failure path; not reproducible on a live handle finally: _KERNEL32.CloseHandle(handle) return (creation.dwHighDateTime << 32) | creation.dwLowDateTime else: # pragma: win32 no cover def process_alive(pid: int) -> bool: """Whether a process with this PID exists, treating an access denial (``EPERM``) as proof it does.""" try: os.kill(pid, 0) except OSError as error: if error.errno == ESRCH: return False if error.errno == EPERM: return True raise return True if sys.platform in {"linux", "android"}: # pragma: linux cover # Termux/Android reports sys.platform == "android" but runs the Linux kernel, so /proc//stat and the boot # id are the same reliable start-time source; treat it exactly like Linux rather than the tokenless fallback. # comm (field 2) is wrapped in parentheses and may itself contain spaces or a ')', so the fixed fields start # after the final ')'. starttime is field 22 overall, the twentieth of those trailing fields (index 19). _STARTTIME_INDEX: Final[int] = 19 def _read_boot_id() -> int: # starttime is measured in clock ticks since boot, so on its own it repeats across a reboot. Folding the # boot id into the high bits makes the Linux token reboot-safe like the absolute clocks macOS and Windows # expose, while staying a single integer so a 3.29 reader still parses the third marker line. 0 when the # kernel does not expose a boot id degrades to bare starttime, which stays safe (a reboot collision fails # closed rather than reclaiming a live marker). try: boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii") return int(boot_id.strip().replace("-", ""), 16) except (OSError, ValueError): # pragma: no cover # the kernel always exposes boot_id as a UUID on Linux return 0 _BOOT_ID: Final[int] = _read_boot_id() def process_start_token(pid: int) -> int | None: """The ``/proc//stat`` ``starttime`` folded with the boot id, or ``None`` when the process is gone.""" try: data = Path(f"/proc/{pid}/stat").read_bytes() except OSError: return None # psutil identifies a process by the same (pid, starttime) pair; the boot id extends that across reboots. fields = data[data.rfind(b")") + 1 :].split() if len(fields) <= _STARTTIME_INDEX: # pragma: no cover # a truncated /proc read, never seen in practice return None try: starttime = int(fields[_STARTTIME_INDEX]) except ValueError: # pragma: no cover # /proc always renders starttime as an integer return None return (_BOOT_ID << 64) | starttime elif sys.platform == "darwin": # pragma: darwin cover import ctypes import struct _LIBC: Final[ctypes.CDLL] = ctypes.CDLL(None, use_errno=True) _CTL_KERN: Final[int] = 1 _KERN_PROC: Final[int] = 14 _KERN_PROC_PID: Final[int] = 1 # kinfo_proc opens with kp_proc.p_starttime (a struct timeval) at offset 0: int64 seconds, int32 microseconds. # The offset is fixed by the struct chain kinfo_proc -> extern_proc -> p_un, so a read at 0 is not a guess. _TIMEVAL_AT_ZERO: Final[str] = " int | None: """The process start time in microseconds from ``sysctl(KERN_PROC_PID)``, or ``None`` when it is gone.""" mib = (ctypes.c_int * 4)(_CTL_KERN, _KERN_PROC, _KERN_PROC_PID, pid) length = ctypes.c_size_t(0) # The size probe reports the kinfo_proc size for any PID, so the read below, not the probe, tells a live # process from a gone one: a gone PID leaves the fetch a zero-length success, so re-check the length after. if _LIBC.sysctl(mib, 4, None, ctypes.byref(length), None, 0) != 0: return None buffer = (ctypes.c_char * length.value)() if _LIBC.sysctl(mib, 4, buffer, ctypes.byref(length), None, 0) != 0 or length.value < _TIMEVAL_SIZE: return None seconds, microseconds = struct.unpack_from(_TIMEVAL_AT_ZERO, buffer.raw, 0) return seconds * 1_000_000 + microseconds else: # pragma: no cover # a POSIX platform without a proven start-time source falls back to fail-closed liveness def process_start_token(pid: int) -> int | None: """No proven start-time source, so the owner carries no token and liveness rests on the PID alone.""" del pid return None __all__ = [ "host_name", "owner_is_stale", "process_alive", "process_start_token", ] filelock-3.32.2/src/filelock/_lease.py0000644000000000000000000003546013615410400014502 0ustar00from __future__ import annotations import os import secrets import time from contextlib import suppress from dataclasses import dataclass from threading import Event, Thread, current_thread, local from typing import TYPE_CHECKING, Literal from ._error import LeaseSettingsMismatch from ._identity import owner_is_stale from ._marker import MarkerSoftFileLock, OwnerMode, OwnerRecord, parse_marker from ._soft import _read_lock_file from ._util import break_lock_file, touch if TYPE_CHECKING: import sys from collections.abc import Callable from ._api import LockOptions if sys.version_info >= (3, 11): # pragma: no cover (py311+) from typing import Unpack else: # pragma: no cover ( None: self.claim = _LeaseClaim() class _ThreadLocalLeaseClaimHolder(_LeaseClaimHolder, local): """A thread local version of the ``_LeaseClaimHolder`` class.""" class SoftFileLease(MarkerSoftFileLock): """ Existence lock whose claim expires, so a peer may take it while the previous holder still runs. A lease trades mutual exclusion for progress. The holder publishes a claim and refreshes it every ``heartbeat_interval`` seconds; a contender takes the marker once it is ``lease_duration`` seconds stale. Nothing stops the expired holder: it keeps running, and it keeps using whatever the lock protects. Treat the lease as a hint about who *should* be working, not as a guarantee that only one worker is. To make a protected resource reject a superseded holder, that resource must be linearizable and must fence on a monotonic generation it controls. :attr:`token` names a claim; it does not fence one. Where overlap is unacceptable, use :class:`StrictSoftFileLock ` instead. Every contender for a path must agree on ``lease_duration``. A contender that finds a claim published under a different duration raises :class:`LeaseSettingsMismatch ` rather than apply its own expiry to a peer that never agreed to it. Expiry reclaims less on Windows, which refuses to rename or delete a file another process holds open. A peer there takes an expired claim only once the previous holder's process exits and its handle closes; a holder that lives on but stops refreshing keeps the marker. Unix reclaims the marker either way. ``on_compromise`` fires from the heartbeat thread when a refresh fails, or when the marker vanishes or names another owner. The holder should stop touching the protected resource when it runs. Because it runs on that thread, a ``release()`` inside it only takes effect when the lease was built with ``thread_local=False``; the default thread-local context hides the claim from every thread but the one that acquired it, so the release does nothing. Signal the acquiring thread instead when the context stays thread-local. .. versionadded:: 3.30.0 """ _owner_mode: OwnerMode = "lease" #: lease_duration replaces the legacy age-based lifetime, so accepting both would give one lock two expiry clocks. _lifetime_supported: bool = False _lifetime_unsupported_reason: str = "lease_duration sets when a lease expires" def __init__( self, lock_file: str | os.PathLike[str], *, lease_duration: float = 30.0, heartbeat_interval: float | None = None, on_compromise: Callable[[LeaseCompromise], None] | None = None, **kwargs: Unpack[LockOptions], ) -> None: """ Create a lease. :param lease_duration: seconds of marker staleness after which a contender may take the claim. Every contender for the path must pass the same value. :param heartbeat_interval: seconds between refreshes. Defaults to a third of ``lease_duration``, leaving room for two missed refreshes before a peer may take the claim. Must be shorter than ``lease_duration``. :param on_compromise: called from the heartbeat thread with a :class:`LeaseCompromise` when the claim is lost. :param kwargs: every other :class:`BaseFileLock ` option, ``timeout`` and ``mode`` among them. The metaclass passes them all by keyword, and taking them here lets :class:`AsyncSoftFileLease ` add the async plumbing a fixed signature would hide. """ if lease_duration <= 0: msg = f"lease_duration must be positive, got {lease_duration!r}" raise ValueError(msg) if heartbeat_interval is None: heartbeat_interval = lease_duration / 3 if not 0 < heartbeat_interval < lease_duration: msg = f"heartbeat_interval must be positive and below lease_duration, got {heartbeat_interval!r}" raise ValueError(msg) super().__init__(lock_file, **kwargs) self._lease_duration = lease_duration self._heartbeat_interval = heartbeat_interval self._on_compromise = on_compromise # Sharing one claim across a thread-local lock lets a second thread's failed acquisition stop the heartbeat of # the thread holding the lease, leaving its marker unrefreshed until a peer reclaims it. self._claims: _LeaseClaimHolder = ( _ThreadLocalLeaseClaimHolder if self.is_thread_local() else _LeaseClaimHolder )() @property def _claim(self) -> _LeaseClaim: return self._claims.claim @property def lease_duration(self) -> float: """The staleness in seconds after which a contender may take this claim.""" return self._lease_duration @property def token(self) -> str | None: """ The token naming the claim this process published. :returns: the token while the lease is held, ``None`` otherwise. It identifies a claim; it does not fence one. """ return self._claim.token @property def compromise(self) -> LeaseCompromise | None: """ The loss of claim the heartbeat observed. :returns: the :class:`LeaseCompromise`, or ``None`` while the claim still holds """ return self._claim.compromise def _acquire(self) -> None: claim = self._claim self._stop_heartbeat() # no earlier claim's heartbeat outlives the acquisition of the next one claim.token = token = secrets.token_hex(16) claim.compromise = None super()._acquire() # The context is thread-local by default, so the heartbeat thread cannot read the descriptor this one just # published, nor the claim this one owns. Hand it the fd, the inode it verified and the claim instead. if (fd := self._context.lock_file_fd) is not None and ( identity := self._context.lock_file_fd_identity ) is not None: self._start_heartbeat(claim, fd, identity, token) def _release(self) -> None: self._stop_heartbeat() self._claim.token = None super()._release() 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: 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 # window and leaves a legitimate legacy or strict holder in place, so a corrupt marker no longer wedges # every lease contender until its own timeout. super()._try_break_stale_lock() return owner, mtime, ino = peer # Only a peer that published a lease agreed to be superseded by one, so a record stating any other contract is # never reclaimed by age. Raise the mismatch outside the read so the suppression cannot swallow it. if owner.mode != "lease": return if owner.lease_duration != self._lease_duration: msg = ( f"{self.lock_file} holds a lease of {owner.lease_duration!r}s but this contender configured " 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; a live owner past its lease duration is superseded on the # schedule every contender agreed to. if owner_is_stale(owner.pid, owner.hostname, owner.start): break_lock_file(self.lock_file, mtime, ino) return if time.time() - mtime >= self._lease_duration: break_lock_file(self.lock_file, mtime, ino) def _read_peer(self) -> tuple[OwnerRecord, float, int] | None: with suppress(OSError, ValueError): content, mtime, ino = _read_lock_file(self.lock_file) if (owner := parse_marker(content)) is not None: return owner, mtime, ino return None def _start_heartbeat(self, claim: _LeaseClaim, fd: int, identity: tuple[int, int], token: str) -> None: # The thread watches the event it was handed rather than whatever the claim names later: a heartbeat that # outlives its join timeout would otherwise adopt the next acquisition's event and never stop. stop = Event() thread = Thread( target=self._refresh_until_stopped, args=(claim, fd, identity, token, stop), name=f"filelock-lease-{os.getpid()}", daemon=True, ) # Record the heartbeat before starting the thread so a release racing this acquire on a shared, # non-thread-local claim always sees it and sets the stop event; the thread then exits at its first wait # instead of outliving the release. A start that raises leaves the unstarted thread for _stop_heartbeat. claim.heartbeat = _Heartbeat(thread, stop) thread.start() def _stop_heartbeat(self) -> None: claim = self._claim if (heartbeat := claim.heartbeat) is None: return heartbeat.stop.set() claim.heartbeat = None # thread.ident is None until start() runs: a heartbeat recorded before its thread started (a start that # raised, or a release racing acquire on a shared claim) has nothing to join, and the stop above makes it # exit at once. on_compromise runs on the heartbeat thread and may release the lease, landing back here. if heartbeat.thread.ident is not None and heartbeat.thread is not current_thread(): heartbeat.thread.join(timeout=self._heartbeat_interval) def _refresh_until_stopped( self, claim: _LeaseClaim, fd: int, identity: tuple[int, int], token: str, stop: Event, ) -> None: # The loop ends at the first loss of the claim, so the holder hears about it once. A transient filesystem # error (ESTALE / EIO on the NFS-style filesystems a lease targets) is not a loss: retry rather than raise a # false compromise. Report the claim unrefreshable only once failures have run long enough that a contender # could take it before the next success would land, a margin before the marker actually ages out, the way # restic declares a lock unrefreshable ahead of its stale time. last_success = time.monotonic() while not stop.wait(self._heartbeat_interval): outcome, error = self._refresh_claim(claim, fd, identity, token) if outcome == "lost": return if outcome == "ok": last_success = time.monotonic() elif time.monotonic() - last_success >= self._lease_duration - self._heartbeat_interval: self._report_compromise(claim, "refresh-failed", error, token) return def _refresh_claim( self, claim: _LeaseClaim, fd: int, identity: tuple[int, int], token: str, ) -> tuple[_RefreshOutcome, OSError | None]: try: st = os.lstat(self.lock_file) except FileNotFoundError as error: self._report_compromise(claim, "marker-missing", error, token) return "lost", None except OSError as error: return "transient", error # A peer that took the expired claim replaced the marker, so the pathname now names its inode, not ours. if (st.st_dev, st.st_ino) != identity: self._report_compromise(claim, "owner-changed", None, token) return "lost", None try: touch(self.lock_file, fd=fd) except OSError as error: return "transient", error return "ok", None def _report_compromise( self, claim: _LeaseClaim, reason: CompromiseReason, error: OSError | None, token: str, ) -> None: # Record it on the claim this heartbeat serves, not on self._claim: a thread-local claim read from the # heartbeat thread is a different, empty one, so the holder would never see the loss it is being told about. # The token is the one this thread published, not claim.token, which a release may already have cleared. claim.compromise = LeaseCompromise(lock_file=self.lock_file, token=token, reason=reason, error=error) if self._on_compromise is not None: self._on_compromise(claim.compromise) __all__ = [ "CompromiseReason", "LeaseCompromise", "SoftFileLease", ] filelock-3.32.2/src/filelock/_marker.py0000644000000000000000000001361213615410400014665 0ustar00from __future__ import annotations import math import os from contextlib import suppress from typing import Final, Literal, NamedTuple from ._identity import host_name, process_start_token from ._soft import SoftFileLock, _read_lock_file from ._util import 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 #: and evicts it after its grace period, so the two never guarantee mutual exclusion against each other. _PROTOCOL: Final[str] = "filelock/2" _MAX_PID: Final[int] = 2**31 - 1 #: ``unknown`` is never published: it names a mode some other filelock wrote that this version cannot interpret. Such a #: record still identifies a live owner, so it is parsed rather than read as malformed and aged out. OwnerMode = Literal["lease", "unknown"] class OwnerRecord(NamedTuple): """The owner published in a protocol 2 marker.""" pid: int hostname: str mode: OwnerMode token: str | None = None lease_duration: float | None = None start: int | None = None class MarkerSoftFileLock(SoftFileLock): """An existence lock whose marker carries a protocol 2 owner record.""" #: Filled in by each mode so the published record states the contract its holder acquired under. _owner_mode: OwnerMode @property def owner(self) -> OwnerRecord | None: """ The owner named by the marker on disk. :returns: the published record, or ``None`` when no marker exists or its record is malformed or protocol 1 """ return self._read_owner() @property def pid(self) -> int | None: """ The PID of the process holding this lock, read from the marker. :returns: the PID, or ``None`` when no marker exists or its record is unreadable """ return None if (owner := self._read_owner()) is None else owner.pid @property def is_lock_held_by_us(self) -> bool: """ Whether the marker on disk names this process. :returns: ``True`` when the marker's PID and hostname match this process """ owner = self._read_owner() return owner is not None and owner.pid == os.getpid() and owner.hostname == host_name() def force_break(self) -> None: """ Remove the marker whoever holds it, so a later contender can acquire. Forced breaking voids mutual exclusion: the previous holder keeps running and keeps using whatever the lock protects. Reserve it for an operator clearing a marker whose holder is known to be gone. """ self.break_lock() def _read_owner(self) -> OwnerRecord | None: with suppress(OSError, ValueError): return parse_marker(_read_lock_file(self.lock_file)[0]) return None def _write_lock_info(self, fd: int) -> None: write_all(fd, encode_marker(self._published_record())) def _published_record(self) -> OwnerRecord: return OwnerRecord( pid=os.getpid(), hostname=host_name(), mode=self._owner_mode, start=process_start_token(os.getpid()), ) def encode_marker(record: OwnerRecord) -> bytes: """Render an owner record as the bytes a protocol 2 marker holds.""" lines = [_PROTOCOL, f"pid={record.pid}", f"host={record.hostname}", f"mode={record.mode}"] if record.token is not None: lines.append(f"token={record.token}") if record.lease_duration is not None: lines.append(f"duration={record.lease_duration!r}") if record.start is not None: lines.append(f"start={record.start}") return "".join(f"{line}\n" for line in lines).encode() def parse_marker(content: str | None) -> OwnerRecord | None: """Return the owner a protocol 2 marker names, or ``None`` when the record is malformed or protocol 1.""" if not content or not (lines := content.strip().splitlines()) or lines[0] != _PROTOCOL: return None fields: dict[str, str] = {} for line in lines[1:]: key, separator, value = line.partition("=") if not separator: return None fields[key] = value return _build_record(fields) def _build_record(fields: dict[str, str]) -> OwnerRecord | None: # An unknown key is a field a newer filelock published, so ignore it rather than read the record as malformed. An # unrecognized mode is the same story one level up: a contract this version does not implement. Reading it as # malformed would age the marker out of a live owner's hands, so keep it and let the caller refuse to reclaim it. # A record naming no mode at all states no contract and stays malformed. if (published := fields.get("mode")) is None: return None mode: OwnerMode = "lease" if published == "lease" else "unknown" hostname = fields.get("host") if not hostname or "pid" not in fields: return None try: pid = int(fields["pid"]) duration = float(fields["duration"]) if "duration" in fields else None start = int(fields["start"]) if "start" in fields else None except ValueError: return None if not 1 <= pid <= _MAX_PID: return None token = fields.get("token") # float() accepts "nan" and "inf", and neither is non-positive, so a duration <= 0 guard alone would read such a # marker as a valid lease. A nan duration mismatches every configured duration and so wedges reclaim, where a # malformed marker ages out through the grace window. if mode == "lease" and (token is None or duration is None or not (math.isfinite(duration) and duration > 0)): return None return OwnerRecord(pid=pid, hostname=hostname, mode=mode, token=token, lease_duration=duration, start=start) __all__ = [ "MarkerSoftFileLock", "OwnerMode", "OwnerRecord", "encode_marker", "parse_marker", ] filelock-3.32.2/src/filelock/_read_write.py0000644000000000000000000010017713615410400015534 0ustar00from __future__ import annotations import logging import os import pathlib import sqlite3 import sys import threading import time from contextlib import contextmanager, suppress from typing import TYPE_CHECKING, ClassVar, Final, Literal, TypeAlias, cast from weakref import WeakValueDictionary from ._api import ( AcquireReturnProxy, _ensure_current_process, _fork_transition, _raise_chained_errors, _register_fork_class, _register_fork_object, ) from ._error import Timeout if TYPE_CHECKING: from collections.abc import Callable, Generator if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self _LOGGER: Final[logging.Logger] = logging.getLogger("filelock") _GETPID: Final[Callable[[], int]] = os.getpid _IS_PYPY: Final[bool] = sys.implementation.name == "pypy" _NEEDS_CONNECTION_ESCROW: Final[bool] = ( hasattr(os, "register_at_fork") and sys.implementation.name == "cpython" and sys.version_info < (3, 12) ) _ConnectionParameter: TypeAlias = ( str | bytes | os.PathLike[str] | os.PathLike[bytes] | float | int | type[sqlite3.Connection] | None ) _DatabaseIdentity: TypeAlias = tuple[int, int] # sqlite3_busy_timeout() accepts a C int, max 2_147_483_647 on 32-bit. Use a lower value to be safe (~23 days). _MAX_SQLITE_TIMEOUT_MS: Final[int] = 2_000_000_000 - 1 _UNSAFE_FORK_EXIT_STATUS: Final[int] = 70 class _SQLiteTransitionContext(threading.local): depth: int = 0 _SQLITE_TRANSITION_CONTEXT: Final = _SQLiteTransitionContext() class _ConnectionEscrow: def __init__(self) -> None: self._lock = threading.RLock() self._functions: tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None = None def functions( self, ) -> tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None: if not _NEEDS_CONNECTION_ESCROW: return None # pragma: >=3.12 cover with self._lock: # pragma: <3.12 cover # pragma: needs fork if self._functions is None: import ctypes # ruff:ignore[import-outside-top-level] # keep optional ctypes and its audited dlsym out of ordinary imports function_type = ctypes.PYFUNCTYPE(None, ctypes.py_object) increment_address = ctypes.cast(ctypes.pythonapi.Py_IncRef, ctypes.c_void_p).value decrement_address = ctypes.cast(ctypes.pythonapi.Py_DecRef, ctypes.c_void_p).value if increment_address is None or decrement_address is None: # pragma: no cover - resolved CPython API msg = "CPython reference functions have no address" raise RuntimeError(msg) self._functions = ( cast("Callable[[sqlite3.Connection], None]", function_type(increment_address)), cast("Callable[[sqlite3.Connection], None]", function_type(decrement_address)), ) return self._functions def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._lock = threading.RLock() _CONNECTION_ESCROW: Final = _ConnectionEscrow() class _ForkedDatabaseRegistry: def __init__(self) -> None: self._lock = threading.RLock() self._paths: set[pathlib.Path] = set() self._identities: set[_DatabaseIdentity] = set() self._sqlite_used = False self._all_paths_poisoned = False def raise_if_poisoned(self, path: pathlib.Path) -> None: identity = self.identity(path) with self._lock: all_paths_poisoned = self._all_paths_poisoned poisoned = ( all_paths_poisoned or path in self._paths or (identity is not None and identity in self._identities) ) if poisoned: # pragma: needs fork msg = ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" if all_paths_poisoned else f"SQLite database {path!s} was active across fork(); exec or exit before using it in the child" ) raise RuntimeError(msg) def poison_after_fork(self, path: pathlib.Path, identity: _DatabaseIdentity | None) -> None: self._paths.add(path) if identity is not None: self._identities.add(identity) def note_sqlite_use(self) -> None: if _IS_PYPY: with self._lock: self._sqlite_used = True def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._lock = threading.RLock() self._all_paths_poisoned = self._all_paths_poisoned or (_IS_PYPY and self._sqlite_used) self._sqlite_used = False @staticmethod def identity(path: pathlib.Path) -> _DatabaseIdentity | None: try: stat_result = path.stat() except OSError: return None return stat_result.st_dev, stat_result.st_ino _FORKED_DATABASES: Final = _ForkedDatabaseRegistry() class _ForkSafeConnection(sqlite3.Connection): _creator_pid: int _decrement_escrow: Callable[[sqlite3.Connection], None] | None def __new__( cls, *_args: _ConnectionParameter, **_kwargs: _ConnectionParameter, ) -> Self: connection = super().__new__(cls) connection._creator_pid = _GETPID() connection._decrement_escrow = None return connection def close(self) -> None: with _sqlite_transition(): if _GETPID() != self._creator_pid: # pragma: needs fork return with _fork_transition(): sqlite3.Connection.close(self) if (decrement := self._decrement_escrow) is not None: # pragma: <3.12 cover # pragma: needs fork self._decrement_escrow = None decrement(self) def acquire_escrow( # pragma: <3.12 cover # pragma: needs fork self, functions: tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None, ) -> None: # The caller only reaches here holding the escrow functions; it skips the call entirely without them. if functions is not None: # pragma: no branch increment, decrement = functions increment(self) self._decrement_escrow = decrement def __del__(self) -> None: with suppress(sqlite3.Error, RuntimeError): self.close() class _ReadWriteLockMeta(type): """ Resolve singleton instances for ``is_singleton=True`` construction. This logic lives here rather than in ReadWriteLock.get_lock so ``ReadWriteLock(path)`` returns cached instances without a 2-arg ``super()`` call that type checkers cannot verify. """ _instances: WeakValueDictionary[pathlib.Path, ReadWriteLock] _instances_lock: threading.RLock _instances_pid: int _instances_under_construction: set[pathlib.Path] def __call__( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, ) -> ReadWriteLock: _ensure_current_process() if cls._instances_pid != _GETPID(): cls._reset_class_after_fork() construction_pid = _GETPID() if not is_singleton: instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) if _GETPID() != construction_pid: # pragma: forked child msg = "ReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) return instance normalized = pathlib.Path(lock_file).resolve() with cls._instances_lock: if normalized not in cls._instances: if normalized in cls._instances_under_construction: # pragma: no cover - exercised in an audit callback msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_registry = cls._instances_under_construction construction_registry.add(normalized) try: instance = super().__call__(lock_file, timeout, blocking=blocking, is_singleton=is_singleton) finally: if _GETPID() == construction_pid: construction_registry.discard(normalized) if _GETPID() != construction_pid: msg = "ReadWriteLock construction cannot continue after fork" raise RuntimeError(msg) cls._instances[normalized] = instance else: instance = cls._instances[normalized] if instance.timeout != timeout or instance.blocking != blocking: msg = ( f"Singleton lock created with timeout={instance.timeout}, blocking={instance.blocking}," f" cannot be changed to timeout={timeout}, blocking={blocking}" ) raise ValueError(msg) return instance def _reset_class_after_fork(cls) -> None: # pragma: forked child cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_pid = _GETPID() cls._instances_under_construction = set() class ReadWriteLock(metaclass=_ReadWriteLockMeta): """ Cross-process read-write lock backed by SQLite. Allows concurrent shared readers or a single exclusive writer. The lock is reentrant within the same mode (multiple ``acquire_read`` calls nest, as do multiple ``acquire_write`` calls from the same thread), but upgrading from read to write or downgrading from write to read raises :class:`RuntimeError`. Write locks are pinned to the thread that acquired them. By default, ``is_singleton=True``: calling ``ReadWriteLock(path)`` with the same resolved path returns the same instance. The path is handed to :func:`sqlite3.connect` as given, so a ``.db`` extension is a convention rather than a requirement; the filesystem must be one the active SQLite VFS supports. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :param is_singleton: if ``True``, reuse existing instances for the same resolved path .. versionadded:: 3.21.0 """ _instances: WeakValueDictionary[pathlib.Path, ReadWriteLock] = WeakValueDictionary() _instances_lock = threading.RLock() _instances_pid = _GETPID() _instances_under_construction: ClassVar[set[pathlib.Path]] = set() def __init_subclass__(cls) -> None: super().__init_subclass__() cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() cls._instances_pid = _GETPID() cls._instances_under_construction = set() _register_fork_class(cls) @classmethod def get_lock( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True ) -> ReadWriteLock: """ Return the singleton :class:`ReadWriteLock` for *lock_file*. :param lock_file: path to the SQLite database file used as the lock :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: the singleton lock instance :raises ValueError: if an instance already exists for this path with different *timeout* or *blocking* values """ return cls(lock_file, timeout, blocking=blocking) def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, # ruff:ignore[unused-method-argument] # consumed by _ReadWriteLockMeta.__call__ ) -> None: self.lock_file = os.fspath(lock_file) self._canonical_path = pathlib.Path(lock_file).resolve() _FORKED_DATABASES.raise_if_poisoned(self._canonical_path) self.timeout = timeout self.blocking = blocking self._transaction_lock = threading.Lock() # serializes the (possibly blocking) SQLite transaction work self._internal_lock = threading.Lock() # protects _lock_level / _current_mode updates and rollback self._lock_level = 0 self._current_mode: Literal["read", "write"] | None = None self._write_thread_id: int | None = None self._acquisition_thread_ids: set[int] = set() self._con: _ForkSafeConnection | None = None self._connection_transaction_released = True self._connection_identity: _DatabaseIdentity | None = None self._closed = False self._creator_pid = _GETPID() self._fork_invalidated = False _register_fork_object(self) with _fork_transition(), _sqlite_transition(): validation_connection = self._open_connection(sqlite_timeout=5.0) validation_connection.close() def acquire_read(self, timeout: float = -1, *, blocking: bool = True) -> AcquireReturnProxy: """ Acquire a shared read lock. If this instance already holds a read lock, the lock level is incremented (reentrant). Attempting to acquire a read lock while holding a write lock raises :class:`RuntimeError` (downgrade not allowed). :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ return self._acquire("read", timeout, blocking=blocking) def acquire_write(self, timeout: float = -1, *, blocking: bool = True) -> AcquireReturnProxy: """ Acquire an exclusive write lock. If this instance already holds a write lock from the same thread, the lock level is incremented (reentrant). Attempting to acquire a write lock while holding a read lock raises :class:`RuntimeError` (upgrade not allowed). Write locks are pinned to the acquiring thread: a different thread trying to re-enter also raises :class:`RuntimeError`. :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ return self._acquire("write", timeout, blocking=blocking) @contextmanager def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ if timeout is None: timeout = self.timeout if blocking is None: blocking = self.blocking self.acquire_read(timeout, blocking=blocking) try: yield finally: self.release() @contextmanager def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default """ if timeout is None: timeout = self.timeout if blocking is None: blocking = self.blocking self.acquire_write(timeout, blocking=blocking) try: yield finally: self.release() def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. When the lock level reaches zero the underlying SQLite transaction is rolled back, releasing the database lock. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ with _fork_transition(): _ensure_current_process() if self._inherited: # pragma: needs fork return self._raise_if_acquiring("release") self._release(force=force, close=False) def close(self) -> None: """ Release the lock (if held) and close the underlying SQLite connection. After calling this method, the lock instance is no longer usable. """ with _fork_transition(): _ensure_current_process() if self._inherited: # pragma: needs fork return self._raise_if_acquiring("close") self._release(force=True, close=True) def _release(self, *, force: bool, close: bool) -> None: with self._transaction_lock, self._internal_lock: if self._lock_level == 0: if force and self._con is None: if close: self._closed = True return if not force: msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" raise RuntimeError(msg) if not force and self._lock_level > 1: self._lock_level -= 1 return try: self._finish_connection() except sqlite3.Error: if self._connection_transaction_released: self._clear_lock_state() raise self._clear_lock_state() if close: self._closed = True def _clear_lock_state(self) -> None: self._lock_level = 0 self._current_mode = None self._write_thread_id = None def __del__(self) -> None: if _GETPID() == getattr(self, "_creator_pid", None) and (connection := getattr(self, "_con", None)) is not None: with suppress(sqlite3.Error, RuntimeError): connection.close() def _reset_after_fork_in_child(self) -> None: # pragma: forked child if self._con is not None: _FORKED_DATABASES.poison_after_fork(self._canonical_path, self._connection_identity) self._con = None self._connection_transaction_released = True self._connection_identity = None self._transaction_lock = threading.Lock() self._internal_lock = threading.Lock() self._clear_lock_state() self._acquisition_thread_ids = set() self._fork_invalidated = True @property def _inherited(self) -> bool: return self._fork_invalidated or _GETPID() != self._creator_pid def _raise_if_unusable(self) -> None: _ensure_current_process() if self._inherited: # pragma: needs fork msg = f"ReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) if self._closed: msg = "Cannot operate on a closed database." raise sqlite3.ProgrammingError(msg) def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy: with _fork_transition(): self._raise_if_unusable() operation_pid = _GETPID() thread_id = threading.get_ident() with self._internal_lock: if self._lock_level > 0: return self._validate_reentrant(mode) if thread_id in self._acquisition_thread_ids: # pragma: no cover - exercised in an audit callback msg = f"Cannot acquire ReadWriteLock on {self.lock_file} while acquisition is active in this thread" raise RuntimeError(msg) self._acquisition_thread_ids.add(thread_id) try: start_time = time.perf_counter() self._acquire_transaction_lock(blocking=blocking, timeout=timeout) try: self._raise_if_unusable() return self._do_acquire_inner( mode, timeout, blocking=blocking, operation_pid=operation_pid, start_time=start_time, ) finally: self._transaction_lock.release() finally: with self._internal_lock: self._acquisition_thread_ids.discard(thread_id) def _do_acquire_inner( self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, operation_pid: int, start_time: float, ) -> AcquireReturnProxy: # Double-check: another thread may have acquired the lock while we waited on _transaction_lock. with self._internal_lock: if self._lock_level > 0: return self._validate_reentrant(mode) if self._con is not None: self._finish_connection() try: self._open_for_acquisition( timeout, blocking=blocking, operation_pid=operation_pid, start_time=start_time, ) self._configure_and_begin( mode, timeout, blocking=blocking, operation=(operation_pid, start_time), ) self._raise_if_process_changed(operation_pid) except BaseException as error: acquisition_error: BaseException if isinstance(error, sqlite3.OperationalError) and "database is locked" in str(error): acquisition_error = Timeout(self.lock_file) else: acquisition_error = error try: self._finish_connection() except sqlite3.Error as cleanup_error: _raise_chained_errors(acquisition_error, cleanup_error) if acquisition_error is not error: raise acquisition_error from None raise with self._internal_lock: self._raise_if_process_changed(operation_pid) self._current_mode = mode self._lock_level = 1 if mode == "write": self._write_thread_id = threading.get_ident() return AcquireReturnProxy(lock=self) def _open_for_acquisition(self, timeout: float, *, blocking: bool, operation_pid: int, start_time: float) -> None: with _sqlite_transition(): sqlite_timeout = ( timeout_for_sqlite( timeout, blocking=blocking, already_waited=time.perf_counter() - start_time, ) / 1000 ) connection = self._open_connection(sqlite_timeout=sqlite_timeout) self._con, self._connection_transaction_released, self._connection_identity = ( connection, False, _FORKED_DATABASES.identity(self._canonical_path), ) self._raise_if_process_changed(operation_pid) def _configure_and_begin( self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, operation: tuple[int, float], ) -> None: with _sqlite_transition(): operation_pid, start_time = operation connection = cast("_ForkSafeConnection", self._con) waited = time.perf_counter() - start_time timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) self._raise_if_process_changed(operation_pid) connection.executescript(f"PRAGMA busy_timeout={timeout_ms}; PRAGMA journal_mode=MEMORY;").close() # Use legacy journal mode (not WAL) because WAL does not block readers while a concurrent EXCLUSIVE # write transaction is active, which makes read-write locking impossible without modifying table data. # MEMORY is safe here since no writes happen, so a crash cannot corrupt the DB. # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions # # Recompute the remaining timeout after the blocking journal_mode pragma. waited = time.perf_counter() - start_time recomputed = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited) self._raise_if_process_changed(operation_pid) statements = f"PRAGMA busy_timeout={recomputed}; " if recomputed != timeout_ms else "" statements += "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;" if mode == "read": # SQLite takes the SHARED lock only when a statement reads; BEGIN alone stays deferred. # https://www.sqlite.org/lockingv3.html#transaction_control statements += " SELECT name FROM sqlite_schema LIMIT 1;" connection.executescript(statements).close() def _open_connection(self, *, sqlite_timeout: float) -> _ForkSafeConnection: with _sqlite_transition(): creator_pid = _GETPID() functions = _CONNECTION_ESCROW.functions() if _GETPID() != creator_pid: # pragma: forked child msg = "SQLite connection construction cannot continue after fork" raise RuntimeError(msg) connection = _connect( os.fspath(self._canonical_path), factory=_ForkSafeConnection, timeout=sqlite_timeout, ) if functions is not None: # pragma: <3.12 cover # pragma: needs fork connection.acquire_escrow(functions) if _GETPID() != creator_pid: # pragma: forked child _FORKED_DATABASES.poison_after_fork( self._canonical_path, _FORKED_DATABASES.identity(self._canonical_path), ) msg = "SQLite connection construction cannot continue after fork" raise RuntimeError(msg) return connection def _finish_connection(self) -> None: with _sqlite_transition(): if (connection := self._con) is None: return rollback_error: sqlite3.Error | None = None if not self._connection_transaction_released: if connection.in_transaction: try: connection.rollback() except sqlite3.Error as error: if connection.in_transaction: raise self._connection_transaction_released = True rollback_error = error else: self._connection_transaction_released = True else: self._connection_transaction_released = True try: connection.close() except sqlite3.Error as close_error: if rollback_error is not None: _raise_chained_errors(rollback_error, close_error) raise self._con = None self._connection_transaction_released = True self._connection_identity = None if rollback_error is not None: raise rollback_error def _validate_reentrant(self, mode: Literal["read", "write"]) -> AcquireReturnProxy: if self._current_mode != mode: opposite = "write" if mode == "read" else "read" direction = "downgrade" if mode == "read" else "upgrade" msg = ( f"Cannot acquire {mode} lock on {self.lock_file} (lock id: {id(self)}): " f"already holding a {opposite} lock ({direction} not allowed)" ) raise RuntimeError(msg) if mode == "write" and (cur := threading.get_ident()) != self._write_thread_id: msg = ( f"Cannot acquire write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {cur} while it is held by thread {self._write_thread_id}" ) raise RuntimeError(msg) self._lock_level += 1 return AcquireReturnProxy(lock=self) def _acquire_transaction_lock(self, *, blocking: bool, timeout: float) -> None: if not blocking: acquired = self._transaction_lock.acquire(blocking=False) elif timeout == -1: acquired = self._transaction_lock.acquire(blocking=True) else: acquired = self._transaction_lock.acquire(blocking=True, timeout=timeout) if not acquired: raise Timeout(self.lock_file) from None def _raise_if_acquiring(self, operation: Literal["acquire", "close", "release"]) -> None: with self._internal_lock: active_in_current_thread = threading.get_ident() in self._acquisition_thread_ids if active_in_current_thread: # pragma: no cover - exercised in an audit callback msg = f"Cannot {operation} ReadWriteLock on {self.lock_file} while acquisition is active in this thread" raise RuntimeError(msg) def _raise_if_process_changed(self, operation_pid: int) -> None: if _GETPID() != operation_pid or self._inherited: # pragma: forked child msg = f"ReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) def _connect(database: str, *, factory: type[_ForkSafeConnection], timeout: float) -> _ForkSafeConnection: _FORKED_DATABASES.note_sqlite_use() return sqlite3.connect( database, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout, ) @contextmanager def _sqlite_transition() -> Generator[None]: _SQLITE_TRANSITION_CONTEXT.depth += 1 try: yield finally: _SQLITE_TRANSITION_CONTEXT.depth -= 1 def _abort_forked_sqlite_transition() -> None: # pragma: forked child if _SQLITE_TRANSITION_CONTEXT.depth: os._exit(_UNSAFE_FORK_EXIT_STATUS) # inherited SQLite handles cannot be used or closed safely def _track_sqlite_use(event: str, _args: tuple[object, ...]) -> None: # audit payloads are heterogeneous if event == "sqlite3.connect": _FORKED_DATABASES.note_sqlite_use() def timeout_for_sqlite(timeout: float, *, blocking: bool, already_waited: float) -> int: if blocking is False: return 0 if timeout == -1: return _MAX_SQLITE_TIMEOUT_MS if timeout < 0: msg = "timeout must be a non-negative number or -1" raise ValueError(msg) timeout_ms = int((max(timeout - already_waited, 0) if timeout > 0 else timeout) * 1000) if timeout_ms > _MAX_SQLITE_TIMEOUT_MS or timeout_ms < 0: _LOGGER.warning("timeout %s is too large for SQLite, using %s ms instead", timeout, _MAX_SQLITE_TIMEOUT_MS) return _MAX_SQLITE_TIMEOUT_MS return timeout_ms _register_fork_object(_CONNECTION_ESCROW) _register_fork_object(_FORKED_DATABASES) _register_fork_class(ReadWriteLock) if _IS_PYPY: sys.addaudithook(_track_sqlite_use) # pragma: pypy cover if hasattr(os, "register_at_fork"): # pragma: needs fork os.register_at_fork(after_in_child=_abort_forked_sqlite_transition) __all__ = [ "ReadWriteLock", "timeout_for_sqlite", ] filelock-3.32.2/src/filelock/_soft.py0000644000000000000000000003132613615410400014361 0ustar00from __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 ` and :class:`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. """ #: Existence locks reclaim by unlinking a pathname, so an age-based lease may break one; a native inode lock cannot. _lifetime_supported: bool = True #: Age-based expiry preserves historical behavior but does not provide strict mutual exclusion. _lifetime_replacements: tuple[str, str] | None = ("StrictSoftFileLock", "SoftFileLease") #: An existence lock unlinks its marker to release, so it cannot promise to keep the pathname. _preserve_lock_file_supported: bool = False #: 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 def _acquire(self) -> None: raise_on_not_writable_file(self.lock_file) ensure_directory_exists(self.lock_file) # O_CREAT | O_EXCL makes the create fail with EEXIST when the file already exists, so a successful open # means this process now holds the lock. flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow 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") ): # pragma: win32 no cover 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: # Publish held state only once the record is fully on disk. On any failure, including cancellation, close the # descriptor and unlink the path only while it still names the file we opened, so a rollback never deletes a # successor's marker that replaced ours at the same path after our lease expired. 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: # pragma: needs hard-link return holder = _parse_lock_holder(content) if holder is 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 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: # No suppression: a write failure must reach the acquisition rollback so it never publishes a half-written # marker as held state. The optional third line is this process's start token, absent when the platform # exposes no proven start time, in which case a reader falls back to PID-only liveness. 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 # ruff:ignore[assert] # _release runs only while held, so the descriptor is set # Capture the held file's identity before closing so cleanup can refuse to unlink a successor's marker. A # supported lifetime lease lets a peer break our expired marker and create its own at this path before we # release; unlinking by path alone would then delete the successor's lock. identity: tuple[int, int] | None = None with suppress(OSError): identity = _file_identity(os.fstat(fd)) # 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() try: self._close_released_fd(fd, default_suppresses=False) # Marker cleanup must also run for control-flow exceptions, and both failures must remain observable. except BaseException as close_error: try: self._unlink_held_marker(identity) except BaseException as cleanup_error: # ruff:ignore[blind-except] # preserve control-flow cleanup failures _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": # pragma: win32 cover self._windows_unlink_if_ours(identity) else: # pragma: win32 no cover 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: # pragma: win32 cover retry_delay = 0.001 for attempt in range(_UNLINK_MAX_RETRIES): # Windows doesn't immediately release file handles after close, causing EACCES/EPERM on unlink. Recheck # identity each attempt: a failed unlink leaves a window for a successor to replace the marker at this path. try: if _file_identity(os.lstat(self.lock_file)) != identity: return Path(self.lock_file).unlink() except OSError as exc: # ruff:ignore[try-except-in-loop] # each attempt's errno drives the retry choice 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]: # (st_dev, st_ino) names the concrete inode behind a path, so a marker recreated at the same pathname after an # expired lease reads as a different file. CPython populates both on Windows from the volume serial and file index. return st.st_dev, st.st_ino def _read_lock_file(path: str) -> tuple[str | None, float, int]: # 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 # 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 # 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. 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): # pragma: no cover # only a non-regular node swapped in after the lstat 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: # A well-formed lock file is "\n\n" with an optional "\n" third line naming the # holder's process start instant (a filelock 3.29 marker wrote this only on Windows; every platform writes it now). # Anything else (wrong line count, a non-integer PID or start token, empty or unreadable content) is unparsable; # returning None lets the caller treat it as a malformed lock to self-heal rather than a holder. 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 # A pid outside the valid range is a malformed lock, not a holder. Without this, a non-positive pid # reaches os.kill() where 0 / -1 mean "the caller's own process group / every process" so a dead # holder reads as alive and the lock is never reclaimed, while an oversized pid raises OverflowError # (not OSError/ValueError) out of the self-heal path. _parse_marker_bytes already enforces this range. if not 1 <= pid <= 2**31 - 1: return None return pid, lines[1], start_token __all__ = [ "SoftFileLock", ] filelock-3.32.2/src/filelock/_soft_protocol.py0000644000000000000000000000027313615410400016277 0ustar00from __future__ import annotations from typing import Final STRICT_SOFT_SENTINEL_RECORD: Final[str] = "1\nfilelock-strict-v1\x00\n0\n" __all__ = [ "STRICT_SOFT_SENTINEL_RECORD", ] filelock-3.32.2/src/filelock/_strict.py0000644000000000000000000011364713615410400014725 0ustar00from __future__ import annotations import contextlib import errno import os import secrets import stat import sys import tempfile import time from dataclasses import dataclass from errno import EACCES, EEXIST, ENOENT, ENOSYS, EPERM, ESTALE, EXDEV from pathlib import Path from typing import TYPE_CHECKING, Final, Literal, cast from ._api import BaseFileLock, _canonical, _raise_cleanup_errors from ._error import SoftFileLockProtocolError from ._identity import host_name, process_start_token from ._soft_protocol import STRICT_SOFT_SENTINEL_RECORD from ._util import ensure_directory_exists, write_all if TYPE_CHECKING: from collections.abc import Iterator StrictSoftFileClaimState = Literal["intent", "held"] _CLAIM_STATES: Final[frozenset[str]] = frozenset({"intent", "held"}) _COORDINATION_SUFFIX: Final[str] = ".filelock" _CLAIM_DIRECTORY_NAME: Final[str] = "claims" _CLAIM_MAGIC: Final[str] = "filelock-strict-v1" _CLAIM_RECORD_LIMIT: Final[int] = 1024 _CLAIM_NAME_PART_COUNT: Final[int] = 3 _TOKEN_HEX_LENGTH: Final[int] = 32 _PRIVATE_RECORD_MARKER: Final[str] = ".private-v1-" _PRIVATE_RECORD_SUFFIX: Final[str] = ".tmp" _PRIVATE_RECORD_RANDOM_HEX_LENGTH: Final[int] = 32 _PRIVATE_RECORD_GRACE: Final[float] = 2.0 _UNLINK_MAX_RETRIES: Final[int] = 10 #: How long a scan waits out a claim held in Windows' delete-pending state before treating it as unreadable. _CLAIM_READ_GRACE: Final[float] = 0.5 _CLAIM_READ_RETRY: Final[float] = 0.002 #: Windows opens descriptors in text mode by default, which rewrites newlines and truncates a record at a control byte. #: The claim and sentinel records are exact binary, so every record descriptor must be binary; POSIX ignores the flag. _O_BINARY: Final[int] = getattr(os, "O_BINARY", 0) _LEGACY_SENTINEL: Final[bytes] = STRICT_SOFT_SENTINEL_RECORD.encode() _WINDOWS_HARD_LINK_UNSUPPORTED: Final[frozenset[int]] = frozenset({1, 17, 50}) # Termux/Android CPython ships without os.link (bionic long had only linkat), so the strict backend's whole hard-link # mechanism is absent there. Probe once and gate every os.link reference on it, so importing filelock still works and # only an actual StrictSoftFileLock acquire reports the missing capability. _HAS_LINK: Final[bool] = hasattr(os, "link") # Probe dir_fd capability once at import. A per-call ``os.unlink in os.supports_dir_fd`` check flips to False the moment # a test mocks os.unlink, silently diverting the code to a different branch than the one under test. _OPEN_SUPPORTS_DIR_FD: Final[bool] = os.open in os.supports_dir_fd _UNLINK_SUPPORTS_DIR_FD: Final[bool] = os.unlink in os.supports_dir_fd _STAT_SUPPORTS_DIR_FD: Final[bool] = os.stat in os.supports_dir_fd _LINK_SUPPORTS_DIR_FD: Final[bool] = _HAS_LINK and os.link in os.supports_dir_fd def _probe_link_follow_symlinks() -> bool: # os.supports_follow_symlinks lists os.link on PyPy, but its linkat then rejects follow_symlinks=False with EINVAL, # and Windows raises NotImplementedError for the option outright. Link a throwaway file for real so the answer # reflects the runtime rather than its advertisement, and treat any failure as "not honored": the option only # hardens a source this process created with O_EXCL, so skipping it is safe, and a real environment fault surfaces # when the actual link runs. if not _HAS_LINK: return False try: with tempfile.TemporaryDirectory() as directory: source = Path(directory, "probe-source") source.touch() os.link(source, Path(directory, "probe-link"), follow_symlinks=False) except (OSError, NotImplementedError, ValueError): return False return True _LINK_HONORS_FOLLOW_SYMLINKS: Final[bool] = _probe_link_follow_symlinks() def _probe_hard_link_unsupported_errnos() -> frozenset[int]: # GraalPy's errno omits ENOTSUP, so importing the name outright breaks every runtime that ships without it. ENOTSUP # wins wherever it exists, leaving every runtime that names it unchanged. EOPNOTSUPP only stands in for the ones # that do not, and it approximates rather than matches: the two codes agree on Linux but differ on macOS/BSD and on # Windows. Where neither exists a runtime that cannot name "operation not supported" cannot raise it either, and # ENOSYS/EXDEV still classify the link failures it can raise. not_supported = getattr(errno, "ENOTSUP", getattr(errno, "EOPNOTSUPP", None)) return frozenset({ENOSYS, EXDEV} if not_supported is None else {ENOSYS, EXDEV, not_supported}) _HARD_LINK_UNSUPPORTED_ERRNOS: Final[frozenset[int]] = _probe_hard_link_unsupported_errnos() class StrictSoftFileLock(BaseFileLock): """Portable fail-closed lock based on immutable owner claims.""" _preserve_lock_file_supported: bool = True _on_acquired_supported: bool = False #: Age cannot clear a strict claim: expiring one on a clock is the overlap the fail-closed contract exists to rule #: out, so only force_break() removes it. _lifetime_supported: bool = False _lifetime_unsupported_reason: str = "a strict claim is never broken by age, only by force_break()" #: The claim doorway publishes an intent and a held record per owner, so a shared instance must serialize them. _serialize_transitions: bool = True #: Contending processes each publish and rescan several files, so back their retries off across a jittered window #: rather than let them collide on every poll. Seconds; keeps a waiter responsive once it wins. _poll_backoff_cap: float = 0.05 def _acquire(self) -> None: # Resolve once per acquisition, not per poll: a waiter on a relative path must keep publishing into the # directory it started waiting in even when another thread changes the working directory mid-wait. if (claim_root := self._context.claim_root) is None: claim_root = self._context.claim_root = _canonical(self.lock_file) lock_path = Path(claim_root) coordination_directory = Path(f"{lock_path}{_COORDINATION_SUFFIX}") claim_directory = coordination_directory / _CLAIM_DIRECTORY_NAME ensure_directory_exists(os.fspath(lock_path)) _ensure_protocol_directory(self.lock_file, coordination_directory) _ensure_protocol_directory(self.lock_file, claim_directory) if (sentinel_fd := _open_or_create_sentinel(self.lock_file, lock_path, self._open_mode())) is None: return try: sentinel_identity = _file_identity(os.fstat(sentinel_fd)) except BaseException as inspection_error: # preserve inspection and descriptor cleanup errors try: os.close(sentinel_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve inspection and descriptor cleanup errors _raise_cleanup_errors("strict sentinel inspection cleanup failed", inspection_error, close_error) raise self._mark_descriptor_pending(sentinel_fd, sentinel_identity) try: self._attempt_doorway(claim_directory, sentinel_fd, sentinel_identity) except BaseException: if self._context.pending_lock_file_fd == sentinel_fd: self._discard_doorway(sentinel_fd, sentinel_identity) raise def _attempt_doorway(self, claim_directory: Path, sentinel_fd: int, sentinel_identity: tuple[int, int]) -> None: if _read_existing_claims(self.lock_file, claim_directory): self._discard_doorway(sentinel_fd, sentinel_identity) return token = secrets.token_hex(_TOKEN_HEX_LENGTH // 2) intent_name = _claim_name("intent", token) intent_path = str(claim_directory / intent_name) try: publication_cleanup_error = _publish_record(intent_path, _claim_record(token), self._open_mode()) except _PrivateRecordReclaimedError: self._discard_doorway(sentinel_fd, sentinel_identity) return except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(self.lock_file, error) if isinstance(error, OSError) and error.errno == EEXIST: self._discard_doorway(sentinel_fd, sentinel_identity) return raise if publication_cleanup_error is not None: # pragma: needs dir-fd raise publication_cleanup_error self._context.owner_claim_paths = (intent_path,) claims = _read_existing_claims(self.lock_file, claim_directory) if ( not claims or any(claim.state == "held" for claim in claims) or min(claim.name for claim in claims) != intent_name ): self._discard_doorway(sentinel_fd, sentinel_identity) return held_name = _claim_name("held", token) held_path = str(claim_directory / held_name) try: link_cleanup_error = _link_no_replace(claim_directory, intent_name, held_name) except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(self.lock_file, error) raise self._context.owner_claim_paths = (held_path, intent_path) if link_cleanup_error is not None: # pragma: needs dir-fd self._context.owner_claim_paths = () raise link_cleanup_error claims = _read_existing_claims(self.lock_file, claim_directory) if ( not {intent_name, held_name}.issubset(claim.name for claim in claims) or min(_claim_token_key(claim.name) for claim in claims) != f"v1-{token}.claim" ): self._discard_doorway(sentinel_fd, sentinel_identity) return # Keep the intent claim for the whole hold rather than unlinking it now. The intent has existed, unchanged, # since this owner published it, so a contender's os.scandir is guaranteed to return it (POSIX only leaves the # visibility of entries created or removed *during* a scan unspecified). The freshly linked held claim carries # no such guarantee: a scan that races its creation can miss it. Were the intent removed here, that scan could # observe neither claim and let a larger-token contender win over this owner. The stable intent is the witness # that keeps the phase-five min-token decision computed over the true set. Release unlinks both. self._mark_descriptor_owned(sentinel_fd, sentinel_identity) @property def claims(self) -> tuple[StrictSoftFileClaim, ...]: """Published claims that block acquisition.""" return _read_existing_claims(self.lock_file, self._claim_directory) def force_break(self, claim_name: str) -> None: """Remove one named claim, allowing overlap if its owner still holds the protected resource.""" _validate_force_break_name(claim_name) _require_exact_name(self._claim_directory, claim_name) if ( cleanup_error := _unlink_in_directory(self._claim_directory, claim_name) ) is not None: # pragma: needs dir-fd raise cleanup_error def _rollback_failed_acquire(self, acquisition_error: BaseException) -> None: # _acquire already reconciles a failed doorway through _discard_doorway: it either closes the pending # descriptor or, when a held claim cannot be removed, commits it as owned so a later release retries and # raises the cleanup errors. A base rollback would release that owned descriptor again and report each # failure a second time, so leave the reconciled state alone. if self.is_locked: return super()._rollback_failed_acquire(acquisition_error) def _reconcile_failed_acquire(self, canonical: str) -> None: # The acquisition is over, so the next one resolves the working directory again rather than reuse this one's. if not self.is_locked: self._context.claim_root = None super()._reconcile_failed_acquire(canonical) def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) self._context.claim_root = None remaining, errors = _unlink_owner_paths(self._context.owner_claim_paths) self._context.owner_claim_paths = tuple(remaining) if remaining: _raise_recorded_errors("strict claim release failed", errors) self._mark_descriptor_released() try: self._close_released_fd(fd, default_suppresses=False) except BaseException as close_error: # ruff:ignore[blind-except] # preserve claim and sentinel cleanup errors errors.append(close_error) if errors: _raise_recorded_errors("strict release cleanup failed", errors) def _discard_doorway(self, fd: int, identity: tuple[int, int]) -> None: remaining, errors = _unlink_owner_paths(self._context.owner_claim_paths) self._context.owner_claim_paths = tuple(remaining) if remaining: self._mark_descriptor_owned(fd, identity) _raise_recorded_errors("strict doorway claim cleanup failed", errors) self._mark_descriptor_released() try: self._close_released_fd(fd, default_suppresses=False) except BaseException as close_error: # ruff:ignore[blind-except] # preserve claim and sentinel cleanup errors errors.append(close_error) if errors: _raise_recorded_errors("strict doorway cleanup failed", errors) @property def _claim_directory(self) -> Path: if self._context.owner_claim_paths: return Path(self._context.owner_claim_paths[0]).parent return Path(f"{_canonical(self.lock_file)}{_COORDINATION_SUFFIX}") / _CLAIM_DIRECTORY_NAME @dataclass(frozen=True) class StrictSoftFileClaim: """One parsed strict soft-lock claim.""" name: str state: StrictSoftFileClaimState token: str pid: int hostname: str #: The owner's process start token, or ``None`` when the platform exposes no proven start time. A strict lock never #: reclaims a claim on its own, so this identifies the owner for tooling rather than driving any automatic break. start: int | None = None class _PrivateRecordReclaimedError(Exception): pass def _open_or_create_sentinel(lock_file: str, path: Path, mode: int) -> int | None: try: return _open_sentinel(path) except FileNotFoundError: pass except OSError: return None _reclaim_sentinel_private_records(path, time.time()) try: publication_cleanup_error = _publish_record(os.fspath(path), _LEGACY_SENTINEL, mode) except _PrivateRecordReclaimedError: return None except (NotImplementedError, OSError) as error: _raise_if_hard_links_unsupported(lock_file, error) if not isinstance(error, OSError) or error.errno != EEXIST: raise else: if publication_cleanup_error is not None: # pragma: needs dir-fd raise publication_cleanup_error try: return _open_sentinel(path) except OSError: return None def _open_sentinel(path: Path) -> int | None: fd, record = _open_record(path, len(_LEGACY_SENTINEL)) if record == _LEGACY_SENTINEL: return fd os.close(fd) return None def _read_claims(lock_file: str, directory: Path) -> tuple[StrictSoftFileClaim, ...]: try: with os.scandir(directory) as entries: names = _public_claim_names(directory, entries) except OSError as error: reason = f"cannot list claim directory: {error.strerror or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, None, reason) from error claims: list[StrictSoftFileClaim] = [] for name in names: if (name_parts := _parse_claim_name(name)) is None: raise SoftFileLockProtocolError(lock_file, name, "unknown claim name or protocol version") if (record := _read_claim_record(lock_file, directory, name)) is not None: claims.append(_parse_claim(lock_file, name, name_parts, record)) return tuple(claims) def _read_claim_record(lock_file: str, directory: Path, name: str) -> bytes | None: # A contended scan can list a claim that is not yet cleanly readable, in two ways that both resolve on a brief # retry. Windows holds a claim mid-unlink in a delete-pending state that fails an open with EACCES until the unlink # completes. On NFS, a peer that unlinks its own claim leaves this client's cached filehandle stale, so the next # open returns ESTALE rather than a clean ENOENT until the lookup revalidates against the server. Retrying re-runs # the path lookup, which turns the vanished claim into ENOENT (skip) or reads it if it still exists. A genuinely # unreadable record (a locked-down file, an EIO fault) still fails closed. deadline = time.monotonic() + _CLAIM_READ_GRACE delaying = False while True: if delaying: time.sleep(_CLAIM_READ_RETRY) record, pending = _attempt_claim_read(lock_file, directory, name) if pending is None: return record if time.monotonic() >= deadline: if pending.errno == ESTALE: # A stale handle that outlives revalidation is a claim the server no longer has (RFC 1813 # NFS3ERR_STALE): skip it like ENOENT. Skipping a peer's vanished claim can only overcount # contention, never free a held lock. return None reason = f"cannot read claim: {pending.strerror or str(pending) or type(pending).__name__}" raise SoftFileLockProtocolError(lock_file, name, reason) from pending delaying = True def _attempt_claim_read(lock_file: str, directory: Path, name: str) -> tuple[bytes | None, OSError | None]: # Return the record, or (None, None) when the claim has already gone, or (None, error) for an open the caller may # retry: a Windows delete-pending EACCES that resolves to a clean removal, or an NFS ESTALE from a peer unlinking # its own claim out from under this client's cached filehandle. Any other OSError is a real fault and fails closed. try: return _read_record(directory / name, _CLAIM_RECORD_LIMIT), None except FileNotFoundError: return None, None except PermissionError as error: return None, error except OSError as error: if error.errno == ESTALE: return None, error reason = f"cannot read claim: {error.strerror or str(error) or type(error).__name__}" raise SoftFileLockProtocolError(lock_file, name, reason) from error def _public_claim_names(directory: Path, entries: Iterator[os.DirEntry[str]]) -> list[str]: names: list[str] = [] now = time.time() for entry in entries: if not entry.name.startswith("."): names.append(entry.name) elif (public_name := _private_public_name(entry.name)) is not None and _parse_claim_name( public_name ) is not None: _reclaim_private_record((os.fspath(directory), None), entry.name, now) return sorted(names) def _read_existing_claims(lock_file: str, directory: Path) -> tuple[StrictSoftFileClaim, ...]: if not directory.exists(): return () return _read_claims(lock_file, directory) def _parse_claim( lock_file: str, name: str, name_parts: tuple[StrictSoftFileClaimState, str], record: bytes, ) -> StrictSoftFileClaim: try: magic, token, pid_text, hostname_hex, start_text, trailing = record.decode("ascii").split("\n") pid = int(pid_text) start = int(start_text) if start_text else None hostname = bytes.fromhex(hostname_hex).decode("utf-8") except (UnicodeDecodeError, ValueError) as error: raise SoftFileLockProtocolError(lock_file, name, "malformed claim record") from error if not all(( not trailing, magic == _CLAIM_MAGIC, token == name_parts[1], 1 <= pid <= 2**31 - 1, str(pid) == pid_text, start is None or (start >= 0 and str(start) == start_text), hostname.encode().hex() == hostname_hex and hostname.isprintable() and not any(character.isspace() for character in hostname), )): raise SoftFileLockProtocolError(lock_file, name, "malformed claim record") return StrictSoftFileClaim(name=name, state=name_parts[0], token=token, pid=pid, hostname=hostname, start=start) def _parse_claim_name(name: str) -> tuple[StrictSoftFileClaimState, str] | None: if not name.endswith(".claim"): return None parts = name.removesuffix(".claim").split("-") if len(parts) != _CLAIM_NAME_PART_COUNT or parts[0] not in _CLAIM_STATES or parts[1] != "v1": return None token = parts[2] if len(token) != _TOKEN_HEX_LENGTH or any(character not in "0123456789abcdef" for character in token): return None return cast("StrictSoftFileClaimState", parts[0]), token def _claim_name(state: StrictSoftFileClaimState, token: str) -> str: return f"{state}-v1-{token}.claim" def _claim_token_key(name: str) -> str: return name.removeprefix("held-").removeprefix("intent-") def _claim_record(token: str) -> bytes: hostname_hex = host_name().encode().hex() start = process_start_token(os.getpid()) start_text = "" if start is None else str(start) return f"{_CLAIM_MAGIC}\n{token}\n{os.getpid()}\n{hostname_hex}\n{start_text}\n".encode("ascii") def _publish_record( public_path: str, record: bytes, mode: int, ) -> BaseException | None: directory, public_name = os.path.split(public_path) directory = directory or os.curdir private_name = _private_record_name(public_name) directory_fd = _open_directory(directory) if _OPEN_SUPPORTS_DIR_FD else None directory_ref = directory, directory_fd try: _publish_record_in_directory(directory_ref, (private_name, public_name), mode, record) except BaseException as publication_error: # preserve publication and directory cleanup errors try: if directory_fd is not None: # pragma: needs dir-fd os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # pragma: needs dir-fd # preserve publication and directory cleanup errors _raise_cleanup_errors("strict publication directory cleanup failed", publication_error, close_error) raise if directory_fd is not None: # pragma: needs dir-fd try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # caller records the published path before raising return close_error return None def _publish_record_in_directory( directory_ref: tuple[str, int | None], names: tuple[str, str], mode: int, record: bytes, ) -> None: flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | _O_BINARY if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: # pragma: needs o-nofollow flags |= o_nofollow private_fd = _open_relative(directory_ref, names[0], flags, mode) private_identity: tuple[int, int] | None = None try: private_identity = _file_identity(os.fstat(private_fd)) write_all(private_fd, record) _link_private_record(directory_ref, names, private_identity) except BaseException as publication_error: # preserve publication and cleanup errors close_error, unlink_error = _close_and_unlink_private_record( directory_ref, names[0], private_fd, private_identity, ) if close_error is not None or unlink_error is not None: _raise_cleanup_errors( "strict record publication cleanup failed", publication_error, close_error, unlink_error, ) raise close_error, unlink_error = _close_and_unlink_private_record( directory_ref, names[0], private_fd, private_identity, ) if close_error is not None or unlink_error is not None: _raise_record_finalization_errors(close_error, unlink_error) def _close_and_unlink_private_record( directory_ref: tuple[str, int | None], private_name: str, private_fd: int, private_identity: tuple[int, int] | None, ) -> tuple[BaseException | None, BaseException | None]: close_error: BaseException | None = None try: os.close(private_fd) except BaseException as error: # ruff:ignore[blind-except] # returned for grouping with unlink failures close_error = error unlink_error: BaseException | None = None try: if private_identity is None: _unlink_relative(directory_ref, private_name) else: _unlink_relative_if_identity(directory_ref, private_name, private_identity) except FileNotFoundError: pass except BaseException as error: # ruff:ignore[blind-except] # returned for grouping with close failures unlink_error = error return close_error, unlink_error def _link_private_record( directory_ref: tuple[str, int | None], names: tuple[str, str], private_identity: tuple[int, int], ) -> None: try: _link_relative(directory_ref, *names) except FileNotFoundError as error: if _relative_identity(directory_ref, names[0]) is not None: raise msg = "private publication record was reclaimed" raise _PrivateRecordReclaimedError(msg) from error if _relative_identity(directory_ref, names[1]) == private_identity: return msg_0 = "private publication record was replaced" raise _PrivateRecordReclaimedError(msg_0) def _raise_record_finalization_errors( close_error: BaseException | None, unlink_error: BaseException | None, ) -> None: errors = [error for error in (close_error, unlink_error) if error is not None] if len(errors) > 1: _raise_cleanup_errors("strict record finalization failed", errors[0], *errors[1:]) raise errors[0] def _private_record_name(public_name: str) -> str: return f".{public_name}{_PRIVATE_RECORD_MARKER}{secrets.token_hex(_PRIVATE_RECORD_RANDOM_HEX_LENGTH // 2)}.tmp" def _private_public_name(private_name: str) -> str | None: if not private_name.startswith(".") or not private_name.endswith(_PRIVATE_RECORD_SUFFIX): return None public_name, marker, random_hex = private_name[1 : -len(_PRIVATE_RECORD_SUFFIX)].rpartition(_PRIVATE_RECORD_MARKER) if ( marker != _PRIVATE_RECORD_MARKER or len(random_hex) != _PRIVATE_RECORD_RANDOM_HEX_LENGTH or any(character not in "0123456789abcdef" for character in random_hex) ): return None return public_name def _reclaim_sentinel_private_records(path: Path, now: float) -> None: directory_ref = os.fspath(path.parent), None with os.scandir(path.parent) as entries: for entry in entries: if _private_public_name(entry.name) == path.name: _reclaim_private_record(directory_ref, entry.name, now) def _reclaim_private_record(directory_ref: tuple[str, int | None], private_name: str, now: float) -> None: directory, directory_fd = directory_ref try: private_stat = ( os.stat(private_name, dir_fd=directory_fd, follow_symlinks=False) if directory_fd is not None and _STAT_SUPPORTS_DIR_FD else Path(directory, private_name).lstat() ) except FileNotFoundError: return if not stat.S_ISREG(private_stat.st_mode): msg = f"{Path(directory, private_name)} is not a regular private record" raise OSError(msg) if private_stat.st_nlink == 1 and now - private_stat.st_mtime < _PRIVATE_RECORD_GRACE: return try: _unlink_private_record_once(directory_ref, private_name) except FileNotFoundError: pass except OSError as error: if error.errno not in {EACCES, EPERM}: raise def _unlink_private_record_once(directory_ref: tuple[str, int | None], private_name: str) -> None: directory, directory_fd = directory_ref if ( directory_fd is not None and _UNLINK_SUPPORTS_DIR_FD ): # pragma: no cover # callers always pass directory_fd=None os.unlink(private_name, dir_fd=directory_fd) else: Path(directory, private_name).unlink() def _relative_identity(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: directory, directory_fd = directory_ref try: if directory_fd is not None and _STAT_SUPPORTS_DIR_FD: # pragma: needs dir-fd path_stat = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) else: # pragma: win32 cover path_stat = Path(directory, name).lstat() except FileNotFoundError: return None return _file_identity(path_stat) def _unlink_relative_if_identity( directory_ref: tuple[str, int | None], name: str, identity: tuple[int, int], ) -> None: if _relative_identity(directory_ref, name) == identity: with contextlib.suppress(FileNotFoundError): _unlink_relative(directory_ref, name) def _open_relative(directory_ref: tuple[str, int | None], name: str, flags: int, mode: int) -> int: directory, directory_fd = directory_ref return ( os.open(name, flags, mode, dir_fd=directory_fd) if directory_fd is not None else os.open(Path(directory, name), flags, mode) ) def _link_relative(directory_ref: tuple[str, int | None], source_name: str, destination_name: str) -> None: directory, directory_fd = directory_ref if directory_fd is not None and _LINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd _link_no_follow(source_name, destination_name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) return _link_no_follow(Path(directory, source_name), Path(directory, destination_name)) # pragma: win32 cover def _link_no_follow( source: str | Path, destination: str | Path, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, ) -> None: if not _HAS_LINK: # No os.link at all (Termux/Android): report it as unsupported like a filesystem that refuses hard links, so # the acquire path raises SoftFileLockProtocolError instead of a bare AttributeError. msg = "os.link is unavailable on this platform" raise NotImplementedError(msg) # The source is a private record this process created with O_CREAT | O_EXCL, so follow_symlinks guards nothing an # attacker can reach. Pass the option only when the runtime honors it: PyPy advertises it through # os.supports_follow_symlinks yet its linkat rejects it with EINVAL, so probe once rather than trust the set. if _LINK_HONORS_FOLLOW_SYMLINKS: os.link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=False) else: # pragma: lacks link-follow-symlinks os.link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd) def _unlink_relative(directory_ref: tuple[str, int | None], name: str) -> None: directory, directory_fd = directory_ref if directory_fd is not None and _UNLINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd os.unlink(name, dir_fd=directory_fd) elif sys.platform == "win32": # pragma: win32 cover _unlink_in_directory(Path(directory), name) else: Path(directory, name).unlink() # pragma: win32 no cover def _link_no_replace(directory: Path, source_name: str, destination_name: str) -> BaseException | None: if _LINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd directory_fd = _open_directory(str(directory)) try: _link_no_follow(source_name, destination_name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) except BaseException as link_error: # preserve link and directory cleanup errors try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve link and directory cleanup errors _raise_cleanup_errors("strict link directory cleanup failed", link_error, close_error) raise try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # caller records the held path before raising return close_error return None _link_no_follow(directory / source_name, directory / destination_name) # pragma: win32 cover return None # pragma: win32 cover def _unlink_owner_path(path: str) -> BaseException | None: directory, name = os.path.split(path) return _unlink_in_directory(Path(directory or os.curdir), name) def _unlink_owner_paths(paths: tuple[str, ...]) -> tuple[list[str], list[BaseException]]: results = [(path, _unlink_owner_path_result(path)) for path in paths] return [path for path, (removed, _) in results if not removed], [ error for _, (_, error) in results if error is not None ] def _unlink_owner_path_result(path: str) -> tuple[bool, BaseException | None]: try: cleanup_error = _unlink_owner_path(path) except FileNotFoundError: return True, None except BaseException as error: # ruff:ignore[blind-except] # keep ownership when unlink did not commit return False, error return True, cleanup_error def _raise_recorded_errors(message: str, errors: list[BaseException]) -> None: if len(errors) > 1: _raise_cleanup_errors(message, errors[0], *errors[1:]) raise errors[0] def _unlink_in_directory(directory: Path, name: str) -> BaseException | None: if _UNLINK_SUPPORTS_DIR_FD: # pragma: needs dir-fd directory_fd = _open_directory(str(directory)) try: os.unlink(name, dir_fd=directory_fd) except BaseException as unlink_error: # preserve unlink and directory cleanup errors try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve unlink and directory cleanup errors _raise_cleanup_errors("strict unlink directory cleanup failed", unlink_error, close_error) raise try: os.close(directory_fd) except BaseException as close_error: # ruff:ignore[blind-except] # caller commits the removed path before raising return close_error return None if sys.platform != "win32": # pragma: win32 no cover Path(directory / name).unlink() return None retry_delay = 0.001 # pragma: win32 cover for attempt in range(_UNLINK_MAX_RETRIES): # pragma: win32 cover if (error := _unlink_error(directory / name)) is None: return None if error.errno not in {EACCES, EPERM} or attempt == _UNLINK_MAX_RETRIES - 1: raise error time.sleep(retry_delay) retry_delay *= 2 return None # pragma: no cover # the final retry returns or raises def _unlink_error(path: Path) -> OSError | None: # pragma: win32 cover try: path.unlink() except OSError as error: return error return None def _open_directory(directory: str) -> int: # pragma: needs dir-fd return os.open( directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), ) def _read_record(path: Path, limit: int) -> bytes: fd, record = _open_record(path, limit) os.close(fd) return record def _open_record(path: Path, limit: int) -> tuple[int, bytes]: path_stat = path.lstat() if not stat.S_ISREG(path_stat.st_mode): msg = f"{path} is not a regular file" raise OSError(msg) flags = os.O_RDONLY | _O_BINARY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) fd = os.open(path, flags) try: record = _read_opened_record(fd, path, path_stat, limit) except BaseException as read_error: # preserve read and descriptor cleanup errors try: os.close(fd) except BaseException as close_error: # ruff:ignore[blind-except] # preserve read and descriptor cleanup errors _raise_cleanup_errors("strict record read cleanup failed", read_error, close_error) raise return fd, record def _read_opened_record(fd: int, path: Path, path_stat: os.stat_result, limit: int) -> bytes: opened_stat = os.fstat(fd) if not stat.S_ISREG(opened_stat.st_mode): msg = f"{path} is not a regular file" raise OSError(msg) if _file_identity(opened_stat) != _file_identity(path_stat): msg = f"{path} changed while opening" raise OSError(msg) record = os.read(fd, limit + 1) if len(record) > limit: msg = f"{path} exceeds {limit} bytes" raise OSError(msg) return record def _ensure_protocol_directory(lock_file: str, directory: Path) -> None: try: directory.mkdir() except FileExistsError: try: mode = directory.lstat().st_mode except OSError as error: raise SoftFileLockProtocolError(lock_file, None, f"cannot inspect {directory}") from error if stat.S_ISDIR(mode) and not stat.S_ISLNK(mode): return raise SoftFileLockProtocolError(lock_file, None, f"{directory} is not a real directory") from None def _validate_force_break_name(name: str) -> None: invalid_component = not name or name.startswith(".") or name in {os.curdir, os.pardir} if invalid_component or any(separator in name for separator in ("/", "\\", "\x00")): msg = "claim_name must be one public claim basename" raise ValueError(msg) def _require_exact_name(directory: Path, name: str) -> None: with os.scandir(directory) as entries: if not any(entry.name == name for entry in entries): raise FileNotFoundError(ENOENT, os.strerror(ENOENT), name) def _raise_if_hard_links_unsupported(lock_file: str, error: NotImplementedError | OSError) -> None: unsupported = ( isinstance(error, NotImplementedError) or error.errno in _HARD_LINK_UNSUPPORTED_ERRNOS or (getattr(error, "winerror", None) in _WINDOWS_HARD_LINK_UNSUPPORTED) ) if unsupported: reason = "filesystem does not support atomic no-replace hard-link publication" raise SoftFileLockProtocolError(lock_file, None, reason) from error def _file_identity(st: os.stat_result) -> tuple[int, int]: return st.st_dev, st.st_ino __all__ = [ "StrictSoftFileClaim", "StrictSoftFileClaimState", "StrictSoftFileLock", ] filelock-3.32.2/src/filelock/_unix.py0000644000000000000000000002102213615410400014361 0ustar00from __future__ import annotations import os import sys import warnings from contextlib import suppress from errno import EACCES, EAGAIN, ENOSYS, EWOULDBLOCK from pathlib import Path from typing import Final, cast from ._api import BaseFileLock from ._util import ensure_directory_exists has_fcntl = False if sys.platform == "win32": # pragma: win32 cover class UnixFileLock(BaseFileLock): """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError else: # pragma: win32 no cover try: import fcntl _ = (fcntl.flock, fcntl.LOCK_EX, fcntl.LOCK_NB, fcntl.LOCK_UN) except (ImportError, AttributeError): _FCNTL_UNAVAILABLE: Final[str] = "fcntl is unavailable" def _lock_fd_nonblocking(_fd: int) -> bool: raise OSError(ENOSYS, _FCNTL_UNAVAILABLE) def _unlock_fd(_fd: int) -> None: raise OSError(ENOSYS, _FCNTL_UNAVAILABLE) else: has_fcntl = True # Contention errnos for a nonblocking flock. EAGAIN/EWOULDBLOCK are the usual "held elsewhere" codes; some # filesystems report EACCES instead, so treat it as contention too rather than a permanent error. _CONTENTION_ERRNOS: Final[frozenset[int]] = frozenset({EACCES, EAGAIN, EWOULDBLOCK}) def _lock_fd_nonblocking(fd: int) -> bool: # One nonblocking exclusive flock attempt shared by UnixFileLock and lock_descriptor, so both contend on # the same lock and classify errors identically. The caller owns fd; this never closes it. try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError as exception: if exception.errno in _CONTENTION_ERRNOS: return False raise return True def _unlock_fd(fd: int) -> None: fcntl.flock(fd, fcntl.LOCK_UN) class UnixFileLock(BaseFileLock): """ Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems. We leave the lock file in place after release. Unlinking a locked file on Unix splits waiters across inodes and breaks mutual exclusion for processes that coordinate via the same path. """ def _acquire(self) -> None: missing_flock = self._acquire_native() if missing_flock is not None: self._switch_to_soft_lock(*missing_flock) def _acquire_native(self) -> tuple[int, OSError] | None: ensure_directory_exists(self.lock_file) # Open without O_TRUNC and defer truncation and fchmod until after flock succeeds: a contender that loses # the lock must not truncate the holder's file (erasing caller diagnostics) or change its mode. The winner # truncates and normalizes mode once it owns the lock (#591). open_flags = os.O_RDWR if (o_nofollow := getattr(os, "O_NOFOLLOW", None)) is not None: open_flags |= o_nofollow open_flags |= os.O_CREAT open_mode = self._open_mode() try: fd = os.open(self.lock_file, open_flags, open_mode) except FileNotFoundError: # On FUSE/NFS, os.open(O_CREAT) is not atomic; a split LOOKUP + CREATE lets a concurrent unlink() # delete the file between them. For a valid path, treat ENOENT as transient contention. For an # invalid path (e.g. empty string), re-raise to avoid an infinite retry loop. if self.lock_file and Path(self.lock_file).parent.exists(): return None raise except PermissionError: # Sticky-bit dirs (e.g. /tmp): O_CREAT fails if the file is owned by another user (#317). # Fall back to opening the existing file without O_CREAT. if not Path(self.lock_file).exists(): raise try: fd = os.open(self.lock_file, open_flags & ~os.O_CREAT, open_mode) except FileNotFoundError: return None self._mark_descriptor_pending(fd) try: locked = _lock_fd_nonblocking(fd) except OSError as exception: if exception.errno != ENOSYS: self._mark_descriptor_released() os.close(fd) raise # contention returns False from _lock_fd_nonblocking, so any raise here is a real failure return fd, exception if locked: self._finalize_locked_fd(fd) else: self._mark_descriptor_released() os.close(fd) # contention; let the retry loop try again return None def _switch_to_soft_lock(self, fd: int, missing_flock: OSError) -> 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. raise missing_flock with suppress(OSError): current = os.lstat(self.lock_file) if identity == (current.st_dev, current.st_ino): Path(self.lock_file).unlink() self._fallback_to_soft_lock() self._acquire() def _finalize_locked_fd(self, fd: int) -> None: # Runs with the flock held. Truncate and normalize mode under a guard so any failure closes fd rather than # leaking it and its lock. A concurrent _release() may have unlinked the inode between our open() and # flock() (st_nlink 0), leaving a useless dead-inode lock; drop it and let the retry loop start fresh. keep = False try: stat_result = os.fstat(fd) if stat_result.st_nlink != 0: os.ftruncate(fd, 0) self._apply_explicit_mode(fd) keep = True except OSError: self._mark_descriptor_released() os.close(fd) raise if keep: self._mark_descriptor_owned(fd, (stat_result.st_dev, stat_result.st_ino)) else: self._mark_descriptor_released() os.close(fd) def _apply_explicit_mode(self, fd: int) -> None: if self.has_explicit_mode: with suppress(PermissionError): os.fchmod(fd, self._context.mode) def _fallback_to_soft_lock(self) -> None: # Import lazily: this runs only on the rare flock fallback, and asyncio imports _unix, so a # module-level import of it here would cycle. from ._soft import SoftFileLock # ruff:ignore[import-outside-top-level] warnings.warn("flock not supported on this filesystem, falling back to SoftFileLock", stacklevel=2) from .asyncio import AsyncSoftFileLock, BaseAsyncFileLock # ruff:ignore[import-outside-top-level] self.__class__ = AsyncSoftFileLock if isinstance(self, BaseAsyncFileLock) else SoftFileLock def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) # Retain the descriptor until flock succeeds: a failed unlock leaves the kernel lock held, so is_locked # must keep reporting held for a retry. Once flock commits, clear held state and close as post-unlock # cleanup; a close failure (EIO on FUSE/Docker bind mounts) does not make the kernel lock held again. _unlock_fd(fd) self._mark_descriptor_released() self._close_released_fd(fd, default_suppresses=True) if sys.platform == "win32": # pragma: win32 cover __all__ = ["UnixFileLock", "has_fcntl"] else: # pragma: win32 no cover __all__ = ["UnixFileLock", "_lock_fd_nonblocking", "_unlock_fd", "has_fcntl"] filelock-3.32.2/src/filelock/_util.py0000644000000000000000000001432413615410400014362 0ustar00from __future__ import annotations import os import secrets import stat import sys from errno import EACCES, EIO, EISDIR from pathlib import Path from typing import Final def write_all(fd: int, data: bytes) -> None: """ Write the whole buffer to *fd*, looping over the short writes ``os.write`` is allowed to make. A marker written with a bare ``os.write`` can land partially: a peer reading it mid-write parses a truncated record as malformed or as a foreign holder. Looping until the buffer drains keeps the record atomic in the process and kernel view. No ``fsync``: filelock needs a complete record, not crash-durable storage. :param fd: file descriptor open for writing. :param data: bytes to write in full. :raises OSError: if a write reports zero progress before the record is complete. """ remaining = memoryview(data) while remaining: if (written := os.write(fd, remaining)) == 0: raise OSError(EIO, "os.write wrote 0 bytes before the record was complete") remaining = remaining[written:] def raise_on_not_writable_file(filename: str) -> None: """ Raise an exception if attempting to open the file for writing would fail. Separates files that can never be written from files that are writable but currently locked. :param filename: file to check :raises OSError: as if the file was opened for writing. """ try: # lstat, not stat: settles exists-and-writable in one syscall, and a hostile symlink at the lock path would # make stat inspect the link target, letting an attacker turn a contended acquire into a misleading # PermissionError / IsADirectoryError and probe that target's attributes. The real open passes O_NOFOLLOW and # refuses the symlink anyway. file_stat = os.lstat(filename) except OSError: return # does not exist, or an error the caller cannot act on # No mtime guard: the old `if st_mtime != 0` skip covered NFS/Linux quirks where os.lstat returned an all-zero # struct, which it no longer does. Skipping on mtime 0 let a read-only file or a directory at the lock path pass # as missing, so acquire() blocked forever on an open that cannot succeed. if not (file_stat.st_mode & stat.S_IWUSR): raise PermissionError(EACCES, "Permission denied", filename) if stat.S_ISDIR(file_stat.st_mode): if sys.platform == "win32": # pragma: win32 cover raise PermissionError(EACCES, "Permission denied", filename) raise IsADirectoryError(EISDIR, "Is a directory", filename) # pragma: win32 no cover def ensure_directory_exists(filename: Path | str) -> None: """ Ensure the directory containing the file exists (create it if necessary). :param filename: file. """ Path(filename).parent.mkdir(parents=True, exist_ok=True) def break_lock_file(lock_file: str, mtime_before: float, ino_before: int) -> None: """ Atomically break a stale lock file judged stale at modification time *mtime_before*. Rename the file to a process-private name before unlinking it, so two processes breaking the same lock cannot delete each other's work: only one rename of a given inode wins, the loser gets ``OSError``. After the rename, re-check the file. A newer modification time, or a different inode than *ino_before*, means a peer recreated the lock between the stale decision and the rename, so we grabbed a live file and abort, leaving the renamed file in place. A rollback rename is itself racy, the same trade-off as the soft read/write marker break. The inode check matters because filesystems with coarse modification-time granularity (NFS, FAT) can give a same-second recreation the old mtime, so mtime alone would miss it and unlink a live lock; the inode is the reliable identity, mirroring the token re-check in the soft read/write marker break. ``lstat`` avoids following a hostile symlink swapped in after the decision. The break name carries a random token so it is unguessable and unique per attempt. Without it two breakers in the same process share ``.break.``, and a second break can rename a recreated live lock onto that path in the window between the re-verify ``lstat`` above and the ``unlink`` below, deleting a live lock the inode check just approved. A private name keeps anyone else from targeting our break path, matching the soft read/write marker break. :param lock_file: path to the lock file to break. :param mtime_before: modification time observed when the lock was judged stale. :param ino_before: inode number observed when the lock was judged stale. :raises OSError: if the rename fails (e.g. the file vanished or is not owned in a sticky directory). """ break_path = f"{lock_file}.break.{os.getpid()}.{secrets.token_hex(16)}" Path(lock_file).rename(break_path) try: st_after = os.lstat(break_path) except OSError: return if st_after.st_mtime > mtime_before or st_after.st_ino != ino_before: return Path(break_path).unlink() def touch(name: str, *, fd: int | None = None) -> 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. # Where the platform cannot utime an fd, fall back to a path-based touch that still refuses to follow a # symlink where supported, matching the O_NOFOLLOW reads used elsewhere here. if fd is not None and _SUPPORTS_UTIME_FD: # pragma: needs utime-fd os.utime(fd, None) return os.utime(name, None, follow_symlinks=not _SUPPORTS_UTIME_NOFOLLOW) # 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 # os.utime follows symlinks unless told not to; not every platform can refuse the follow, so probe support. _SUPPORTS_UTIME_NOFOLLOW: Final[bool] = os.utime in os.supports_follow_symlinks __all__ = [ "break_lock_file", "ensure_directory_exists", "raise_on_not_writable_file", "touch", "write_all", ] filelock-3.32.2/src/filelock/_windows.py0000644000000000000000000003716413615410400015106 0ustar00from __future__ import annotations import os import sys from contextlib import suppress from pathlib import Path from typing import Final, cast from ._api import BaseFileLock from ._util import ensure_directory_exists, raise_on_not_writable_file if sys.platform == "win32": # pragma: win32 cover import ctypes import msvcrt from ctypes import wintypes _GENERIC_READ: Final[int] = 0x80000000 _GENERIC_WRITE: Final[int] = 0x40000000 _SYNCHRONIZE: Final[int] = 0x00100000 _DESIRED_ACCESS: Final[int] = _GENERIC_READ | _GENERIC_WRITE | _SYNCHRONIZE _FILE_SHARE_READ_WRITE: Final[int] = ( 0x00000001 | 0x00000002 ) # read | write; matches os.open (_SH_DENYNO), no delete _FILE_OPEN_IF: Final[int] = 3 # open the file if it exists, create it otherwise; the NtCreateFile OPEN_ALWAYS _FILE_ATTRIBUTE_READONLY: Final[int] = 0x00000001 _FILE_ATTRIBUTE_NORMAL: Final[int] = 0x00000080 _FILE_ATTRIBUTE_REPARSE_POINT: Final[int] = 0x00000400 # CreateOptions: keep the handle synchronous (the CRT and msvcrt.locking rely on a maintained file position), # refuse a directory, and open a reparse point rather than following it so the check below acts on the link itself. _FILE_SYNCHRONOUS_IO_NONALERT: Final[int] = 0x00000020 _FILE_NON_DIRECTORY_FILE: Final[int] = 0x00000040 _FILE_OPEN_REPARSE_POINT: Final[int] = 0x00200000 _CREATE_OPTIONS: Final[int] = _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_NON_DIRECTORY_FILE | _FILE_OPEN_REPARSE_POINT _OBJ_CASE_INSENSITIVE: Final[int] = 0x00000040 # Win32 name lookups are case-insensitive _OWNER_WRITE: Final[int] = 0o200 # LockFileEx locks a byte range at an offset carried in OVERLAPPED, independent of the descriptor's file position. # msvcrt.locking starts at the current position instead, so a metadata write between lock and unlock could shift # the byte a later unlock targets; the explicit offset removes that hazard for both the path lock and #608's # descriptor lock. _LOCKFILE_FAIL_IMMEDIATELY: Final[int] = 0x00000001 _LOCKFILE_EXCLUSIVE_LOCK: Final[int] = 0x00000002 _ERROR_LOCK_VIOLATION: Final[int] = 33 # another handle holds the byte range # NtCreateFile returns the raw NTSTATUS as its value, where CreateFileW collapses several of these into one # ERROR_ACCESS_DENIED. Telling them apart is the point (#604): a name pending deletion or a share conflict is # transient and worth a retry, a real access denial is not. _STATUS_SUCCESS: Final[int] = 0x00000000 _STATUS_ACCESS_DENIED: Final[int] = 0xC0000022 _STATUS_SHARING_VIOLATION: Final[int] = 0xC0000043 _STATUS_DELETE_PENDING: Final[int] = 0xC0000056 _ntdll: Final[ctypes.WinDLL] = ctypes.WinDLL("ntdll") _kernel32: Final[ctypes.WinDLL] = ctypes.WinDLL("kernel32", use_last_error=True) class _UNICODE_STRING(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Length", wintypes.USHORT), # byte length, not character count ("MaximumLength", wintypes.USHORT), ("Buffer", wintypes.LPWSTR), ) class _OBJECT_ATTRIBUTES(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Length", wintypes.ULONG), ("RootDirectory", wintypes.HANDLE), ("ObjectName", ctypes.POINTER(_UNICODE_STRING)), ("Attributes", wintypes.ULONG), ("SecurityDescriptor", ctypes.c_void_p), ("SecurityQualityOfService", ctypes.c_void_p), ) class _IO_STATUS_BLOCK(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("Status", ctypes.c_void_p), # a union of NTSTATUS and PVOID, so it is pointer-sized ("Information", ctypes.c_void_p), ) class _OVERLAPPED(ctypes.Structure): # mirrors the Win32 struct name _fields_ = ( ("Internal", ctypes.c_void_p), # ULONG_PTR: pointer-sized, not DWORD, or the x64 layout corrupts Offset ("InternalHigh", ctypes.c_void_p), ("Offset", wintypes.DWORD), # the DUMMYUNIONNAME struct, flattened: low 32 bits of the byte offset ("OffsetHigh", wintypes.DWORD), ("hEvent", wintypes.HANDLE), ) class _BY_HANDLE_FILE_INFORMATION(ctypes.Structure): # ruff:ignore[invalid-class-name] # mirrors the Win32 struct name _fields_ = ( ("dwFileAttributes", wintypes.DWORD), ("ftCreationTime", wintypes.FILETIME), ("ftLastAccessTime", wintypes.FILETIME), ("ftLastWriteTime", wintypes.FILETIME), ("dwVolumeSerialNumber", wintypes.DWORD), ("nFileSizeHigh", wintypes.DWORD), ("nFileSizeLow", wintypes.DWORD), ("nNumberOfLinks", wintypes.DWORD), ("nFileIndexHigh", wintypes.DWORD), ("nFileIndexLow", wintypes.DWORD), ) _ntdll.NtCreateFile.restype = wintypes.LONG # NTSTATUS _ntdll.NtCreateFile.argtypes = [ ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, ctypes.POINTER(_OBJECT_ATTRIBUTES), ctypes.POINTER(_IO_STATUS_BLOCK), ctypes.POINTER(ctypes.c_longlong), # PLARGE_INTEGER AllocationSize wintypes.ULONG, wintypes.ULONG, wintypes.ULONG, wintypes.ULONG, ctypes.c_void_p, wintypes.ULONG, ] _ntdll.RtlDosPathNameToNtPathName_U_WithStatus.restype = wintypes.LONG # NTSTATUS _ntdll.RtlDosPathNameToNtPathName_U_WithStatus.argtypes = [ wintypes.LPCWSTR, ctypes.POINTER(_UNICODE_STRING), ctypes.c_void_p, ctypes.c_void_p, ] _ntdll.RtlFreeUnicodeString.restype = None _ntdll.RtlFreeUnicodeString.argtypes = [ctypes.POINTER(_UNICODE_STRING)] _ntdll.RtlNtStatusToDosError.restype = wintypes.ULONG _ntdll.RtlNtStatusToDosError.argtypes = [wintypes.LONG] _kernel32.CloseHandle.argtypes = [wintypes.HANDLE] _kernel32.CloseHandle.restype = wintypes.BOOL _kernel32.GetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.POINTER(_BY_HANDLE_FILE_INFORMATION)] _kernel32.GetFileInformationByHandle.restype = wintypes.BOOL _kernel32.LockFileEx.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_OVERLAPPED), ] _kernel32.LockFileEx.restype = wintypes.BOOL _kernel32.UnlockFileEx.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(_OVERLAPPED), ] _kernel32.UnlockFileEx.restype = wintypes.BOOL def _lock_fd_nonblocking(fd: int) -> bool: # One nonblocking exclusive LockFileEx attempt shared by WindowsFileLock and lock_descriptor, over the one-byte # range at offset 0. True on acquisition, False on contention, raise otherwise. The caller owns fd; the handle # from get_osfhandle belongs to the CRT descriptor and must not be closed here. overlapped = _OVERLAPPED() # zero-initialized, so Offset/OffsetHigh/hEvent are 0 flags = _LOCKFILE_EXCLUSIVE_LOCK | _LOCKFILE_FAIL_IMMEDIATELY if _kernel32.LockFileEx(msvcrt.get_osfhandle(fd), flags, 0, 1, 0, ctypes.byref(overlapped)): return True err = ctypes.get_last_error() if err == _ERROR_LOCK_VIOLATION: return False # A non-contention LockFileEx failure is not reproducible in-process. raise ctypes.WinError(err) # pragma: no cover def _unlock_fd(fd: int) -> None: overlapped = _OVERLAPPED() # the same offset 0 and one-byte length the lock used # Unlocking the exact range we hold does not fail. if not _kernel32.UnlockFileEx(msvcrt.get_osfhandle(fd), 0, 1, 0, ctypes.byref(overlapped)): # pragma: no cover raise ctypes.WinError(ctypes.get_last_error()) class WindowsFileLock(BaseFileLock): """ Uses ``LockFileEx`` to hard lock a byte range of the lock file on Windows systems. Lock file cleanup: Windows attempts to delete the lock file after release, but deletion is not guaranteed in multi-threaded scenarios where another thread holds an open handle. The lock file may persist on disk, which does not affect lock correctness. """ def _acquire(self) -> None: raise_on_not_writable_file(self.lock_file) ensure_directory_exists(self.lock_file) # The reparse test is bound to the opened handle, so a symlink or junction swapped in cannot defeat it # through a check-then-open TOCTOU race. fd = _open_non_reparse_fd(self.lock_file, self._open_mode()) if fd is None: return # open contention (share conflict or a name pending deletion); let the retry loop try again try: locked = _lock_fd_nonblocking(fd) if locked: self._mark_descriptor_owned(fd) except BaseException: # pragma: no cover # cleanup only if the lock attempt itself raises os.close(fd) raise if not locked: os.close(fd) # another holder owns the byte-range lock; let the retry loop try again def _release(self) -> None: fd = cast("int", self._context.lock_file_fd) # Retain the descriptor until the OS unlock succeeds: if UnlockFileEx raises, the byte-range lock is still # held, so is_locked must keep reporting held rather than losing the fd. Only after the unlock commits do # close and unlink run as post-unlock cleanup; their failure cannot make the lock held again. _unlock_fd(fd) self._mark_descriptor_released() self._close_released_fd(fd, default_suppresses=False) if not self._preserve_lock_file: # preserve_lock_file keeps a stable file identity for the caller (#605) with suppress(OSError): Path(self.lock_file).unlink() def _open_non_reparse_fd(path: str, mode: int) -> int | None: """ Open *path* for locking while refusing reparse points, bound to the handle actually locked. The file is opened through ``NtCreateFile`` with ``FILE_OPEN_REPARSE_POINT`` so a symlink or junction planted at the path is not followed, and the reparse decision is read from *that* handle via ``GetFileInformationByHandle`` rather than from a prior pathname query. Reading the held handle closes the check-then-open race: an attacker cannot swap the path between validation and use because both act on the same handle. Share mode omits delete so a peer cannot unlink or rename the file out from under a live holder, matching ``os.open``'s ``_SH_DENYNO``. ``NtCreateFile`` is used instead of ``CreateFileW`` because its return value carries the raw ``NTSTATUS``. Windows collapses a transient delete-pending name and a permanent access denial into the same Win32 ``ERROR_ACCESS_DENIED``; the status keeps them apart, so a real denial fails fast instead of spinning until the caller's timeout (#604). The reparse option only guards the final path component; Windows still follows reparse points in intermediate directories. This assumes the lock file sits in a lock directory untrusted users cannot modify. A path with attacker-controlled parent directories would need component-by-component handle validation. :param path: the lock file path. :param mode: the permission mode; as ``os.open`` does on Windows, a cleared owner-write bit creates the file read-only. The attribute only takes effect when the file is created, not when an existing one is opened. :returns: a file descriptor owning the opened handle, or ``None`` on a sharing violation or a delete-pending name the caller should treat as contention and retry. :raises OSError: if the path resolves to a reparse point, or the open fails for any other reason, raised with the Win32 error the status maps to. """ # Emit the audit event os.open would, so consumers watching "open" still see the path-level open and can veto. sys.audit("open", path, None, os.O_RDWR | os.O_CREAT) handle, status = _nt_open(path, read_only=not mode & _OWNER_WRITE) if status != _STATUS_SUCCESS: if status in {_STATUS_SHARING_VIOLATION, _STATUS_DELETE_PENDING}: return None winerror = _ntdll.RtlNtStatusToDosError(status) raise OSError(None, ctypes.FormatError(winerror).strip(), path, winerror) info = _BY_HANDLE_FILE_INFORMATION() # Querying an open handle we just created does not fail. if not _kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)): # pragma: no cover err = ctypes.get_last_error() _kernel32.CloseHandle(handle) raise ctypes.WinError(err) if info.dwFileAttributes & _FILE_ATTRIBUTE_REPARSE_POINT: _kernel32.CloseHandle(handle) msg = f"Lock file is a reparse point (symlink/junction): {path}" raise OSError(msg) try: # O_NOINHERIT mirrors os.open on Windows: the lock fd must not leak into child processes. return msvcrt.open_osfhandle(handle, os.O_RDWR | os.O_NOINHERIT) except BaseException: # pragma: no cover # open_osfhandle audits too; a hook raising must not leak the handle _kernel32.CloseHandle(handle) raise def _nt_open(path: str, *, read_only: bool) -> tuple[int, int]: """ Open *path* through ``NtCreateFile`` and return ``(handle, status)``. ``RtlDosPathNameToNtPathName_U_WithStatus`` translates the Win32 path to the NT namespace, handling relative, drive, UNC and extended-length path forms as Win32 itself would, and allocates a buffer that ``RtlFreeUnicodeString`` releases. The handle is ``0`` unless the status is ``STATUS_SUCCESS``. """ nt_name = _UNICODE_STRING() status = _ntdll.RtlDosPathNameToNtPathName_U_WithStatus(path, ctypes.byref(nt_name), None, None) & 0xFFFFFFFF if status != _STATUS_SUCCESS: return 0, status try: attributes = _OBJECT_ATTRIBUTES() attributes.Length = ctypes.sizeof(_OBJECT_ATTRIBUTES) attributes.ObjectName = ctypes.pointer(nt_name) attributes.Attributes = _OBJ_CASE_INSENSITIVE handle = wintypes.HANDLE() io_status = _IO_STATUS_BLOCK() status = ( _ntdll.NtCreateFile( ctypes.byref(handle), _DESIRED_ACCESS, ctypes.byref(attributes), ctypes.byref(io_status), None, _FILE_ATTRIBUTE_READONLY if read_only else _FILE_ATTRIBUTE_NORMAL, _FILE_SHARE_READ_WRITE, _FILE_OPEN_IF, _CREATE_OPTIONS, None, 0, ) & 0xFFFFFFFF ) finally: _ntdll.RtlFreeUnicodeString(ctypes.byref(nt_name)) if status != _STATUS_SUCCESS: return 0, status return handle.value or 0, status else: # pragma: win32 no cover class WindowsFileLock(BaseFileLock): """Uses ``LockFileEx`` to hard lock a byte range of the lock file on Windows systems.""" def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError __all__ = [ "WindowsFileLock", ] filelock-3.32.2/src/filelock/asyncio.py0000644000000000000000000010344713615410400014720 0ustar00"""An asyncio-based implementation of the file lock.""" from __future__ import annotations import asyncio import contextlib import logging import os import time from dataclasses import dataclass from inspect import iscoroutinefunction from threading import local from typing import TYPE_CHECKING, Final, NoReturn, TypeVar, cast from ._api import ( _UNSET_FILE_MODE, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy, FileLockContext, FileLockMeta, _append_exception_context, _canonical, _ExtraValue, _fork_transition, _grouped_errors, _raise_body_and_release, _raise_chained_errors, _raise_cleanup_errors, _raise_grouped_errors, _register_fork_object, ) from ._async import ( _AsyncTransitionGate, _AsyncTransitionUnavailableError, _BackendOutcome, _capture_awaitable, _capture_call, _drain_future, _future_result, _wait_until_done, ) from ._error import Timeout from ._lease import SoftFileLease from ._soft import SoftFileLock from ._strict import StrictSoftFileLock from ._unix import UnixFileLock from ._windows import WindowsFileLock if TYPE_CHECKING: import sys from collections.abc import Awaitable, Callable, Coroutine, Hashable from concurrent import futures from types import TracebackType if sys.version_info >= (3, 11): # pragma: no cover (py311+) from typing import Self else: # pragma: no cover ( _AT: if thread_local and run_in_executor: msg = "run_in_executor is not supported when thread_local is True" raise ValueError(msg) return super().__call__( # a subclass may add options of its own, as AsyncSoftFileLease does **kwargs, lock_file=lock_file, timeout=timeout, mode=mode, thread_local=thread_local, blocking=blocking, is_singleton=is_singleton, poll_interval=poll_interval, lifetime=lifetime, context_error_policy=context_error_policy, close_error_policy=close_error_policy, fallback_to_soft=fallback_to_soft, preserve_lock_file=preserve_lock_file, on_acquired=on_acquired, loop=loop, run_in_executor=run_in_executor, executor=executor, ) class BaseAsyncFileLock(BaseFileLock, metaclass=AsyncFileLockMeta): """ Base class for asynchronous file locks. .. versionadded:: 3.15.0 """ _deadlock_holder_desc: str = "BaseAsyncFileLock instance in this task" _constructor_lifetime_warning_stacklevel: int = 4 @staticmethod def _deadlock_scope() -> Hashable | None: # One event loop thread runs every task, so a thread-scoped registry cannot tell a same-task reacquire # (a real deadlock: the polling task never reaches its own release) from another task queuing behind the # holder (no deadlock: each poll yields, so the holder runs on and releases). Only the first may fail # fast, so scope holders to the task. return asyncio.current_task() def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = _UNSET_FILE_MODE, thread_local: bool = False, # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility *, blocking: bool = True, is_singleton: bool = False, poll_interval: float = 0.05, lifetime: float | None = None, context_error_policy: ContextErrorPolicy = "chain", close_error_policy: CloseErrorPolicy = "default", fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, loop: asyncio.AbstractEventLoop | None = None, run_in_executor: bool = True, executor: futures.Executor | None = None, ) -> None: """ Create a new lock object. :param lock_file: path to the file :param timeout: default timeout when acquiring the lock, in seconds. It will be used as fallback value in the acquire method, if no timeout value (``None``) is given. If you want to disable the timeout, set it to a negative value. A timeout of 0 means that there is exactly one attempt to acquire the file lock. :param mode: file permissions for the lockfile. When not specified, the OS controls permissions via umask and default ACLs, preserving POSIX default ACL inheritance in shared directories. :param thread_local: Whether this object's internal context should be thread local or not. If this is set to ``False`` then the lock will be reentrant across threads. When ``True`` (the default), **all fields of the lock's internal context are per-thread**, including the configuration values ``poll_interval``, ``timeout``, ``blocking``, ``mode``, and ``lifetime``. Setting one of these properties from one thread does not change the value seen by another thread; threads that did not perform the write continue to see the value supplied at construction time. If you need configuration values to be visible across threads, construct the lock with ``thread_local=False``. :param blocking: whether the lock should be blocking or not :param is_singleton: If this is set to ``True`` then only one instance of this class will be created per lock file. This is useful if you want to use the lock object for reentrant locking without needing to pass the same object around. :param poll_interval: default interval for polling the lock file, in seconds. It will be used as fallback value in the acquire method, if no poll_interval value (``None``) is given. :param lifetime: for :class:`AsyncSoftFileLock`, the age in seconds after which a waiting process may delete the marker, even while its holder remains alive. This legacy expiry mode does not provide strict mutual exclusion. ``None`` (the default) disables age-based expiry. Native OS locks (:class:`AsyncFileLock`) cannot be revoked by file age and ignore a non-``None`` ``lifetime`` with a warning. :param context_error_policy: how a context manager reconciles a failure in its body with a failure while releasing on exit. ``"chain"`` (the default) keeps Python's behavior: the release error propagates with the body error in its ``__context__``. ``"group"`` raises a :class:`BaseExceptionGroup` holding the body error first and the release error second, so neither hides the other. :param close_error_policy: for native locks (:class:`AsyncFileLock`), what to do with an ``os.close`` failure after the OS unlock has already committed. ``"default"`` keeps each platform's historical behavior, ``"raise"`` always propagates the ``OSError``, and ``"suppress"`` always ignores it. :param fallback_to_soft: for :class:`AsyncFileLock`, whether to fall back to soft existence locking when ``flock`` returns ``ENOSYS``. ``True`` (default) keeps the fallback; ``False`` propagates the error. :param preserve_lock_file: for native locks (:class:`AsyncFileLock`), whether filelock promises not to unlink the lock pathname on release. ``False`` (default) keeps each backend's cleanup; ``True`` keeps a stable file identity (Windows skips its unlink, Unix refuses the ``ENOSYS`` soft fallback). :class:`AsyncSoftFileLock` rejects ``True``. :param on_acquired: for native locks (:class:`AsyncFileLock`), a callable invoked with the borrowed lock descriptor once per physical acquisition, after the lock is held but before :meth:`~BaseAsyncFileLock.acquire` returns. With ``run_in_executor=True`` (the default) it runs in the backend executor. It must not close or unlock the descriptor; a raise rolls the acquisition back. :class:`AsyncSoftFileLock` rejects it. :param loop: The event loop to use. If not specified, the running event loop will be used. :param run_in_executor: If this is set to ``True`` then the lock will be acquired in an executor. :param executor: The executor to use. If not specified, the default executor will be used. """ self._creator_pid = os.getpid() self._is_thread_local = thread_local self._is_singleton = is_singleton self._context_error_policy = context_error_policy # already validated by the metaclass self._close_error_policy = close_error_policy # already validated by the metaclass self._fallback_to_soft = fallback_to_soft self._preserve_lock_file = preserve_lock_file # already validated by the metaclass self._on_acquired = on_acquired # already validated by the metaclass self._transition_gate: Final[_AsyncTransitionGate] = _AsyncTransitionGate() self._context: AsyncFileLockContext = (AsyncThreadLocalFileContext if thread_local else AsyncFileLockContext)( lock_file=os.fspath(lock_file), timeout=timeout, mode=mode, blocking=blocking, poll_interval=poll_interval, lifetime=lifetime, loop=loop, run_in_executor=run_in_executor, executor=executor, ) _register_fork_object(self) @property def run_in_executor(self) -> bool: """Whether run in executor.""" return self._context.run_in_executor @property def executor(self) -> futures.Executor | None: """The executor.""" return self._context.executor @executor.setter def executor(self, value: futures.Executor | None) -> None: # pragma: no cover """ Change the executor. :param futures.Executor | None value: the new executor or ``None`` """ self._context.executor = value @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop.""" return self._context.loop async def acquire( # ty: ignore[invalid-method-override] self, timeout: float | None = None, poll_interval: float | None = None, *, blocking: bool | None = None, cancel_check: Callable[[], bool] | None = None, ) -> AsyncAcquireReturnProxy: """ Try to acquire the file lock. :param timeout: maximum wait time for acquiring the lock, ``None`` means use the default :attr:`~BaseFileLock.timeout` is and if ``timeout < 0``, there is no timeout and this method will block until the lock could be acquired :param poll_interval: interval of trying to acquire the lock file, ``None`` means use the default :attr:`~BaseFileLock.poll_interval` :param blocking: defaults to True. If False, function will return immediately if it cannot obtain a lock on the first attempt. Otherwise, this method will block until the timeout expires or the lock is acquired. :param cancel_check: a callable returning ``True`` when the acquisition should be canceled. Checked on each poll iteration. When triggered, raises :class:`~Timeout` just like an expired timeout. :returns: a context object that will unlock the file when the context is exited :raises Timeout: if fails to acquire lock within the timeout period .. code-block:: python # You can use this method in the context manager (recommended) with lock.acquire(): pass # Or use an equivalent try-finally construct: lock.acquire() try: pass finally: lock.release() """ self._raise_if_inherited() if timeout is None: timeout = self._context.timeout if blocking is None: blocking = self._context.blocking if poll_interval is None: poll_interval = self._context.poll_interval start_time = time.perf_counter() try: return await self._acquire_with_admission( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except _AsyncTransitionUnavailableError: raise Timeout(self.lock_file) from None async def _acquire_with_admission( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> AsyncAcquireReturnProxy: async with self._transition_gate.hold_for_acquire( blocking=blocking, cancel_check=cancel_check, deadline=None if timeout < 0 else start_time + timeout, poll_interval=poll_interval, ): # A canceled provisional acquire must finish rollback before another caller can claim its descriptor. canonical = _canonical(self.lock_file) self._context.lock_counter += 1 self._raise_if_would_deadlock(canonical, timeout=timeout, blocking=blocking) self._context.claim_root = canonical try: await self._async_poll_until_acquired( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, start_time=start_time, ) except BaseException: self._reconcile_failed_acquire(canonical) raise finally: self._context.claim_root = None self._commit_acquire(canonical) return AsyncAcquireReturnProxy(lock=self) async def _async_poll_until_acquired( self, *, blocking: bool, cancel_check: Callable[[], bool] | None, timeout: float, poll_interval: float, start_time: float, ) -> None: lock_id = id(self) lock_filename = self.lock_file while True: self._raise_if_inherited() if not self.is_locked: self._try_break_expired_lock() _LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename) await self._run_acquire_attempt() self._raise_if_inherited() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) return if self._check_give_up( blocking=blocking, cancel_check=cancel_check, timeout=timeout, start_time=start_time, ): raise Timeout(lock_filename) _LOGGER.debug("Lock %s not acquired on %s, waiting %s seconds ...", lock_id, lock_filename, poll_interval) await asyncio.sleep(poll_interval) async def _run_acquire_attempt(self) -> None: acquire_future = self._start_internal_method( self._acquire_with_fork_tracking_async if iscoroutinefunction(self._acquire) else self._acquire_with_fork_tracking ) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: acquire_error: BaseException | None = None try: await _drain_future(acquire_future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below acquire_error = error rollback_error: BaseException | None = None if self.is_locked: try: await _drain_future(self._start_tracked_release()) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below rollback_error = error if acquire_error is not None: if rollback_error is not None: # pragma: needs fcntl self._raise_cancelled_errors( "lock acquisition cancellation, backend attempt, and rollback failed", cancellation, acquire_error, rollback_error, ) self._raise_cancelled_errors( "lock acquisition cancellation and backend attempt both failed", cancellation, acquire_error ) if rollback_error is not None: self._raise_cancelled_errors( "lock acquisition cancellation and rollback both failed", cancellation, rollback_error ) raise try: _future_result(acquire_future) except asyncio.CancelledError as acquire_error: await self._rollback_backend_cancelled_acquire(acquire_error) async def _rollback_backend_cancelled_acquire(self, acquire_error: asyncio.CancelledError) -> NoReturn: if self.is_locked: try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # both backend errors must surface self._raise_acquire_rollback_errors(acquire_error, rollback_error) raise acquire_error def _raise_acquire_rollback_errors(self, acquire_error: BaseException, rollback_error: BaseException) -> NoReturn: if self._context_error_policy == "group": _raise_grouped_errors("lock acquisition backend and rollback both failed", acquire_error, rollback_error) _raise_chained_errors(acquire_error, rollback_error) def _raise_cancelled_errors( self, message: str, cancellation: asyncio.CancelledError, first_error: BaseException, second_error: BaseException | None = None, ) -> NoReturn: if self._context_error_policy == "group": marker = ( (_ASYNC_RELEASE_CANCELLATION_MARKER_ATTR, _ASYNC_RELEASE_CANCELLATION_MARKER) if message == _ASYNC_RELEASE_CANCELLATION_ERRORS else None ) if second_error is None: _raise_grouped_errors(message, cancellation, first_error, marker=marker) _raise_grouped_errors(message, cancellation, first_error, second_error, marker=marker) if (context := first_error.__context__) is not None and context is not cancellation: if (cancellation_context := cancellation.__context__) is not None: _append_exception_context(context, cancellation_context) cancellation.__context__ = context first_error.__context__ = cancellation _raise_chained_errors(first_error, second_error) async def release(self, force: bool = False) -> None: # ty: ignore[invalid-method-override] # ruff:ignore[boolean-type-hint-positional-argument, boolean-default-value-positional-argument] # public API: positional bool kept for backwards compatibility """ Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file itself may be deleted automatically, the behavior is platform-specific. :param force: If true, the lock counter is ignored and the lock is released in every case. """ if self._creator_pid != os.getpid() or not self.is_locked: return async with self._transition_gate.hold(): await self._release_serialized(force=force) async def _release_serialized(self, *, force: bool) -> None: if not self.is_locked: return if not force and self._context.lock_counter > 1: self._context.lock_counter -= 1 return lock_id, lock_filename = id(self), self.lock_file _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) release_future = self._start_tracked_release() try: await _wait_until_done(release_future) except asyncio.CancelledError as cancellation: try: await _drain_future(release_future) except BaseException as release_error: # ruff:ignore[blind-except] # cancellation and backend failure must both surface self._commit_release_if_released() self._raise_cancelled_errors(_ASYNC_RELEASE_CANCELLATION_ERRORS, cancellation, release_error) self._commit_release() raise try: _future_result(release_future) except BaseException: # state follows the backend for control-flow exceptions too self._commit_release_if_released() raise self._commit_release() _LOGGER.debug("Lock %s released on %s", lock_id, lock_filename) def _commit_release_if_released(self) -> None: # Commit only when the backend actually unlocked (close or unlink failed after the OS unlock). If the lock is # still held, keep the counter so a later release can retry. if not self.is_locked: self._commit_release() def _start_internal_method( self, method: Callable[[], None] | Callable[[], Coroutine[None, None, None]] ) -> asyncio.Future[_BackendOutcome[None]]: if iscoroutinefunction(method): return asyncio.create_task(_capture_awaitable(cast("Callable[[], Coroutine[None, None, None]]", method)())) loop = asyncio.get_running_loop() sync_method = cast("Callable[[], None]", method) if self.run_in_executor: return loop.run_in_executor(self.executor, _capture_call, sync_method) future: asyncio.Future[_BackendOutcome[None]] = loop.create_future() future.set_result(_capture_call(sync_method)) return future def _start_tracked_release(self) -> asyncio.Future[_BackendOutcome[None]]: return self._start_internal_method( self._release_with_fork_tracking_async if iscoroutinefunction(self._release) else self._release_with_fork_tracking ) async def _acquire_with_fork_tracking_async(self) -> None: with _fork_transition(self): try: await cast("Callable[[], Awaitable[None]]", self._acquire)() except BaseException as acquisition_error: await self._rollback_failed_acquire_async(acquisition_error) raise try: self._register_context_descriptor() except BaseException as registration_error: # cancellation must roll back the descriptor await self._rollback_failed_registration_async(registration_error) raise if self.is_locked: await self._invoke_on_acquired_async() async def _rollback_failed_acquire_async(self, acquisition_error: BaseException) -> None: if not self.is_locked: return registration_error: BaseException | None = None tracking_error: BaseException | None = None try: self._register_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # preserve registration and acquisition failures registration_error = error try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and acquisition failures if registration_error is None and tracking_error is None: self._raise_acquire_rollback_errors(acquisition_error, rollback_error) _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error, rollback_error, ) if registration_error is not None: _raise_cleanup_errors( "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error ) async def _rollback_failed_registration_async(self, registration_error: BaseException) -> None: tracking_error: BaseException | None = None try: # Rollback may fail too; retain the fd so a child can close it without another identity probe. self._register_unverified_context_descriptor() except BaseException as error: # ruff:ignore[blind-except] # pragma: no cover - allocation/control-flow during fallback tracking_error = error try: await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # ruff:ignore[blind-except] # preserve rollback and registration failures _raise_cleanup_errors( "descriptor registration cleanup failed", registration_error, tracking_error, rollback_error ) if tracking_error is not None: # pragma: no cover - requires failed in-memory fallback _raise_cleanup_errors("descriptor registration cleanup failed", registration_error, tracking_error) async def _invoke_on_acquired_async(self) -> None: if self._on_acquired is None or self._context.lock_counter != 1: return try: self._on_acquired(cast("int", self._context.lock_file_fd)) except BaseException as callback_error: # caller control-flow errors must release the lock callback_context = callback_error.__context__ try: await _drain_future(self._start_tracked_release()) except BaseException as release_error: # ruff:ignore[blind-except] # both errors surface via the group below _raise_body_and_release(callback_error, release_error) callback_error.__context__ = callback_context raise async def _release_with_fork_tracking_async(self) -> None: with _fork_transition(self): try: await cast("Callable[[], Awaitable[None]]", self._release)() finally: self._unregister_released_descriptor() def __enter__(self) -> NoReturn: """Sync context manager entry is not supported because lock acquisition is a coroutine.""" msg = "Use `async with`: acquire/release are coroutines and cannot be awaited in a sync context manager." raise NotImplementedError(msg) def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Sync context manager exit is not supported because lock release is a coroutine.""" msg = "Use `async with`: acquire/release are coroutines and cannot be awaited in a sync context manager." raise NotImplementedError(msg) async def __aenter__(self) -> Self: """ Acquire the lock. :returns: the lock object """ await self.acquire() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """ Release the lock, reconciling a release failure with any body failure per :attr:`context_error_policy`. :param exc_type: the exception type if raised :param exc_value: the exception value if raised :param traceback: the exception traceback if raised """ await self._release_in_context(exc_value) async def _release_in_context( # ty: ignore[invalid-method-override] self, body_error: BaseException | None ) -> None: # The async counterpart of BaseFileLock._release_in_context: await release, then apply the same policy. try: await self.release() except BaseException as release_error: if body_error is None: raise if self._context_error_policy == "chain": _append_exception_context(release_error, body_error) raise if ( errors := _grouped_errors( release_error, _ASYNC_RELEASE_CANCELLATION_ERRORS, (_ASYNC_RELEASE_CANCELLATION_MARKER_ATTR, _ASYNC_RELEASE_CANCELLATION_MARKER), ) ) is not None: match errors: # The marker is only ever set on a (CancelledError, backend_error) pair, so this always matches. case (asyncio.CancelledError() as cancellation, backend_error): # pragma: no branch _raise_grouped_errors(_ASYNC_CONTEXT_RELEASE_ERRORS, body_error, cancellation, backend_error) _raise_body_and_release(body_error, release_error) def __del__(self) -> None: """Release on deletion; safe to call during GC even when no event loop is running.""" if vars(self).get("_creator_pid") != os.getpid(): return # pragma: forked child with contextlib.suppress(Exception): try: loop = asyncio.get_running_loop() except RuntimeError: loop = self._context.loop if self._context.loop and not self._context.loop.is_closed() else None if loop is None: return if not loop.is_running(): # pragma: no cover loop.run_until_complete(self.release(force=True)) else: loop.create_task(self.release(force=True)) @dataclass class AsyncFileLockContext(FileLockContext): """A dataclass which holds the context for a ``BaseAsyncFileLock`` object.""" #: Whether run in executor run_in_executor: bool = True #: The executor executor: futures.Executor | None = None #: The loop loop: asyncio.AbstractEventLoop | None = None class AsyncThreadLocalFileContext(AsyncFileLockContext, local): """A thread local version of the ``FileLockContext`` class.""" class AsyncAcquireReturnProxy: """A context-aware object that will release the lock file when exiting.""" def __init__(self, lock: BaseAsyncFileLock) -> None: # ruff:ignore[undocumented-public-init] # trivial release-on-exit proxy self.lock = lock async def __aenter__(self) -> BaseAsyncFileLock: # ruff:ignore[undocumented-magic-method] # returns the wrapped lock return self.lock async def __aexit__( # ruff:ignore[undocumented-magic-method] # releases the wrapped lock on exit self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock._release_in_context(exc_value) # ruff:ignore[private-member-access] # releases the wrapped lock's context class AsyncSoftFileLock(SoftFileLock, BaseAsyncFileLock): """Simply watches the existence of the lock file.""" _lifetime_replacements: tuple[str, str] | None = ("AsyncStrictSoftFileLock", "AsyncSoftFileLease") class AsyncStrictSoftFileLock(StrictSoftFileLock, BaseAsyncFileLock): """Run strict owner-claim locking without blocking the event loop.""" class AsyncSoftFileLease(SoftFileLease, BaseAsyncFileLock): """Existence lock whose claim expires, so a peer may take it while the previous holder still runs.""" class AsyncUnixFileLock(UnixFileLock, BaseAsyncFileLock): """Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems.""" class AsyncWindowsFileLock(WindowsFileLock, BaseAsyncFileLock): """Uses the :func:`msvcrt.locking` to hard lock the lock file on windows systems.""" __all__ = [ "AsyncAcquireReturnProxy", "AsyncSoftFileLease", "AsyncSoftFileLock", "AsyncStrictSoftFileLock", "AsyncUnixFileLock", "AsyncWindowsFileLock", "BaseAsyncFileLock", ] filelock-3.32.2/src/filelock/py.typed0000644000000000000000000000000013615410400014355 0ustar00filelock-3.32.2/src/filelock/version.py0000644000000000000000000000101213615410400014721 0ustar00# file generated by vcs-versioning # don't change, don't track in version control from __future__ import annotations __all__ = [ "__version__", "__version_tuple__", "version", "version_tuple", "__commit_id__", "commit_id", ] version: str __version__: str __version_tuple__: tuple[int | str, ...] version_tuple: tuple[int | str, ...] commit_id: str | None __commit_id__: str | None __version__ = version = '3.32.2' __version_tuple__ = version_tuple = (3, 32, 2) __commit_id__ = commit_id = None filelock-3.32.2/src/filelock/_soft_rw/__init__.py0000644000000000000000000000056213615410400016626 0ustar00"""Cross-process and cross-host reader/writer lock on :class:`~filelock.SoftFileLock` primitives.""" from __future__ import annotations from ._async import AsyncAcquireSoftReadWriteReturnProxy, AsyncSoftReadWriteLock from ._sync import SoftReadWriteLock __all__ = [ "AsyncAcquireSoftReadWriteReturnProxy", "AsyncSoftReadWriteLock", "SoftReadWriteLock", ] filelock-3.32.2/src/filelock/_soft_rw/_async.py0000644000000000000000000002634413615410400016351 0ustar00"""Async wrapper around :class:`SoftReadWriteLock` for use with ``asyncio``.""" from __future__ import annotations import asyncio import functools import os from contextlib import asynccontextmanager from typing import TYPE_CHECKING, ParamSpec, TypeVar from filelock._async import ( _BackendOutcome, _capture_call, _drain_future, _future_result, _raise_cancelled_error, _wait_until_done, ) from ._sync import SoftReadWriteLock if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable from concurrent import futures from types import TracebackType from filelock._api import AcquireReturnProxy _P = ParamSpec("_P") _R = TypeVar("_R") class AsyncSoftReadWriteLock: """ Async wrapper around :class:`SoftReadWriteLock` for ``asyncio`` applications. The sync class's blocking filesystem operations run on a thread pool via ``loop.run_in_executor()``. The underlying :class:`SoftReadWriteLock` handles reentrancy, upgrade/downgrade rules, fork handling, heartbeat and TTL stale detection, and singleton behavior. :param lock_file: path to the lock file; sidecar state/write/readers live next to it :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately on contention :param is_singleton: if ``True``, reuse existing :class:`SoftReadWriteLock` instances per resolved path :param heartbeat_interval: seconds between heartbeat refreshes; default 30 s :param stale_threshold: seconds of mtime inactivity before a marker is stale; defaults to ``3 * heartbeat_interval`` :param poll_interval: seconds between acquire retries under contention; default 0.25 s :param loop: event loop for ``run_in_executor``; ``None`` uses the running loop :param executor: executor for ``run_in_executor``; ``None`` uses the default executor .. versionadded:: 3.27.0 """ def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, loop: asyncio.AbstractEventLoop | None = None, executor: futures.Executor | None = None, ) -> None: self._creator_pid = os.getpid() self._lock = SoftReadWriteLock( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) self._loop = loop self._executor = executor @property def lock_file(self) -> str: """The path to the lock file passed to the constructor.""" return self._lock.lock_file @property def timeout(self) -> float: """The default timeout applied when ``acquire_read`` / ``acquire_write`` is called without one.""" return self._lock.timeout @property def blocking(self) -> bool: """Whether ``acquire_*`` defaults to blocking; ``False`` makes contention raise immediately.""" return self._lock.blocking @property def loop(self) -> asyncio.AbstractEventLoop | None: """The event loop used for ``run_in_executor``, or ``None`` for the running loop.""" return self._loop @property def executor(self) -> futures.Executor | None: """The executor used for ``run_in_executor``, or ``None`` for the default executor.""" return self._executor @asynccontextmanager async def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases a shared read lock. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ await self.acquire_read(timeout, blocking=blocking) try: yield finally: await self.release() @asynccontextmanager async def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: """ Async context manager that acquires and releases an exclusive write lock. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ await self.acquire_write(timeout, blocking=blocking) try: yield finally: await self.release() async def acquire_read( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireSoftReadWriteReturnProxy: """ Acquire a shared read lock. See :meth:`SoftReadWriteLock.acquire_read` for reentrancy / upgrade / fork semantics. The blocking work runs inside ``run_in_executor`` so other coroutines on the same loop keep progressing while this call waits. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :returns: a proxy usable as an async context manager to release the lock :raises RuntimeError: if a write lock is already held, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self._raise_if_inherited() await self._run_acquire(functools.partial(self._lock.acquire_read, timeout, blocking=blocking)) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) async def acquire_write( self, timeout: float | None = None, *, blocking: bool | None = None ) -> AsyncAcquireSoftReadWriteReturnProxy: """ Acquire an exclusive write lock. See :meth:`SoftReadWriteLock.acquire_write` for the two-phase writer-preferring semantics. The blocking work runs inside ``run_in_executor``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :returns: a proxy usable as an async context manager to release the lock :raises RuntimeError: if a read lock is already held, if a write lock is held by a different thread, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self._raise_if_inherited() await self._run_acquire(functools.partial(self._lock.acquire_write, timeout, blocking=blocking)) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) async def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ if self._creator_pid == os.getpid(): await self._run(self._lock.release, force=force) async def close(self) -> None: """Release any held lock and release the underlying filesystem resources. Idempotent.""" if self._creator_pid == os.getpid(): await self._run(self._lock.close) def _raise_if_inherited(self) -> None: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"AsyncSoftReadWriteLock on {self.lock_file} was inherited across fork; construct a new instance" raise RuntimeError(msg) async def _run_acquire(self, acquire: Callable[[], AcquireReturnProxy]) -> None: # run_in_executor cannot recall work the pool already started, so canceling the caller does not stop the sync # acquire: it still creates its marker, sets the hold, and starts the heartbeat, which keeps the marker fresh # forever so no peer on any host can evict it as stale. Wait the submitted call out and hand the claim back, # the way AsyncReadWriteLock does. acquire_future = self._submit(acquire) try: await _wait_until_done(acquire_future) except asyncio.CancelledError as cancellation: try: await _drain_future(acquire_future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) try: await _drain_future(self._submit(self._lock.release)) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise _future_result(acquire_future) async def _run(self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs) -> _R: # A canceled release or close is already running on the pool thread; drain it so its outcome is observed # instead of finishing unwatched, then let the cancellation through. future = self._submit(func, *args, **kwargs) try: await _wait_until_done(future) except asyncio.CancelledError as cancellation: try: await _drain_future(future) except BaseException as error: # ruff:ignore[blind-except] # reported with the cancellation below _raise_cancelled_error(cancellation, error) raise return _future_result(future) def _submit( self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs ) -> asyncio.Future[_BackendOutcome[_R]]: loop = self._loop or asyncio.get_running_loop() return loop.run_in_executor(self._executor, _capture_call, functools.partial(func, *args, **kwargs)) class AsyncAcquireSoftReadWriteReturnProxy: """Async context-aware object that releases an :class:`AsyncSoftReadWriteLock` on exit.""" def __init__(self, lock: AsyncSoftReadWriteLock) -> None: self.lock = lock async def __aenter__(self) -> AsyncSoftReadWriteLock: return self.lock async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: await self.lock.release() __all__ = [ "AsyncAcquireSoftReadWriteReturnProxy", "AsyncSoftReadWriteLock", ] filelock-3.32.2/src/filelock/_soft_rw/_sync.py0000644000000000000000000012430113615410400016200 0ustar00"""Cross-process and cross-host reader/writer lock built on :class:`SoftFileLock` primitives.""" from __future__ import annotations import atexit import hmac import os import re import secrets import socket import stat import sys import threading import time import uuid from contextlib import closing, contextmanager, suppress from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Final, Literal from weakref import WeakValueDictionary from filelock._api import ( AcquireReturnProxy, _ensure_current_process, _fork_transition, _raise_grouped_errors, _register_fork_class, _register_fork_object, _register_owned_descriptor, _unregister_owned_descriptor, ) from filelock._error import Timeout from filelock._soft import SoftFileLock from filelock._util import ensure_directory_exists, touch, write_all if TYPE_CHECKING: from collections.abc import Callable, Generator _Mode = Literal["read", "write"] _BREAK_SUFFIX: Final[str] = ".break" _MAX_MARKER_SIZE: Final[int] = 1024 _O_NOFOLLOW: Final[int] = getattr(os, "O_NOFOLLOW", 0) _O_NONBLOCK: Final[int] = getattr(os, "O_NONBLOCK", 0) # dirfd-relative I/O is a Unix-only optimization; Windows cannot ``os.open()`` a directory at all, and # its ``os`` module skips dir_fd support entirely. When disabled, callers fall back to full-path ops. _SUPPORTS_DIR_FD: Final[bool] = sys.platform != "win32" and os.open in os.supports_dir_fd _ALL_INSTANCES: Final[WeakValueDictionary[int, SoftReadWriteLock]] = WeakValueDictionary() _ALL_INSTANCES_LOCK: threading.Lock = threading.Lock() _SINGLETONS_UNDER_CONSTRUCTION: Final[set[Path]] = set() class _SoftRWMeta(type): _instances: WeakValueDictionary[Path, SoftReadWriteLock] _instances_lock: threading.RLock def __call__( # ruff:ignore[too-many-arguments] # forwards the public constructor's documented parameters cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> SoftReadWriteLock: _ensure_current_process() if not is_singleton: return super().__call__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) normalized = Path(lock_file).resolve() with cls._instances_lock: instance = cls._instances.get(normalized) if instance is None: if normalized in _SINGLETONS_UNDER_CONSTRUCTION: # pragma: needs fork msg = f"Singleton lock construction is already active for {lock_file!s}" raise RuntimeError(msg) construction_pid = os.getpid() _SINGLETONS_UNDER_CONSTRUCTION.add(normalized) try: instance = super().__call__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) finally: _SINGLETONS_UNDER_CONSTRUCTION.discard(normalized) if os.getpid() != construction_pid: # pragma: needs fork msg = "Lock construction cannot continue after fork; construct a new lock in the child" raise RuntimeError(msg) cls._instances[normalized] = instance elif instance.timeout != timeout or instance.blocking != blocking: msg = ( f"Singleton lock created with timeout={instance.timeout}, blocking={instance.blocking}," f" cannot be changed to timeout={timeout}, blocking={blocking}" ) raise ValueError(msg) return instance class SoftReadWriteLock(metaclass=_SoftRWMeta): """ Cross-process and cross-host reader/writer lock built on :class:`SoftFileLock` primitives. Use this class instead of :class:`~filelock.ReadWriteLock` when the lock file lives on a network filesystem (NFS, Lustre with ``-o flock``, HPC cluster shared storage). ``ReadWriteLock`` is backed by SQLite and cannot run on NFS because SQLite's ``fcntl`` locking is unreliable there. Layout on disk for a lock at ``foo.lock``: - ``foo.lock.state`` — a :class:`SoftFileLock` taken only during state transitions (microseconds). - ``foo.lock.write`` — writer marker; its presence means a writer is claiming or holding the lock. - ``foo.lock.readers/..`` — one file per reader. Each marker stores a random token (``secrets.token_hex(16)``), the holder's pid, and the holder's hostname. A daemon heartbeat thread refreshes ``mtime`` on every held marker. A marker whose mtime has not advanced in ``stale_threshold`` seconds may be evicted by any process on any host, giving correct behavior when a compute node crashes with a lock held. Writer acquire is two-phase and writer-preferring: phase 1 claims ``.write`` (blocking any new reader), phase 2 waits for existing readers to drain. Writer starvation is impossible. Reentrancy, upgrade/downgrade rules, thread pinning, and singleton caching by resolved path match :class:`~filelock.ReadWriteLock`. Forking invalidates the inherited instance in the child so the child cannot double-own the lock with its parent; ``release()`` on that instance is a no-op, and the child must construct a new instance if it needs a lock. Trust boundary: protects against same-UID non-cooperating processes (one host or cross-host) and same-host different-UID users via ``0o600`` / ``0o700`` permissions. Does not protect against root compromise, NTP tampering on same-UID cross-host nodes, or multi-tenant mounts where hostile co-tenants share the UID. :param lock_file: path to the lock file; sidecar state/write/readers live next to it :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately on contention :param is_singleton: if ``True``, reuse existing instances for the same resolved path :param heartbeat_interval: seconds between heartbeat refreshes; default 30 s :param stale_threshold: seconds of ``mtime`` inactivity before a marker is stale; defaults to ``3 * heartbeat_interval``, matching etcd's ``LeaseKeepAlive`` convention :param poll_interval: seconds between acquire retries under contention; default 0.25 s .. versionadded:: 3.27.0 """ _instances: WeakValueDictionary[Path, SoftReadWriteLock] = WeakValueDictionary() _instances_lock = threading.RLock() def __init__( # ruff:ignore[too-many-arguments] # public constructor: one parameter per documented lock option self, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, # ruff:ignore[unused-method-argument] # consumed by _SoftRWMeta.__call__ heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> None: self._creator_pid = os.getpid() if heartbeat_interval <= 0: msg = f"heartbeat_interval must be positive, got {heartbeat_interval}" raise ValueError(msg) if stale_threshold is None: stale_threshold = heartbeat_interval * 3 if stale_threshold <= heartbeat_interval: msg = f"stale_threshold must exceed heartbeat_interval ({stale_threshold} <= {heartbeat_interval})" raise ValueError(msg) if poll_interval <= 0: msg = f"poll_interval must be positive, got {poll_interval}" raise ValueError(msg) self.lock_file: str = os.fspath(lock_file) self.timeout: float = timeout self.blocking: bool = blocking self.heartbeat_interval: float = heartbeat_interval self.stale_threshold: float = stale_threshold self.poll_interval: float = poll_interval self._paths = _Paths( state=f"{self.lock_file}.state", write=f"{self.lock_file}.write", readers=f"{self.lock_file}.readers", ) ensure_directory_exists(self.lock_file) self._locks = _Locks( internal=threading.Lock(), transaction=threading.Lock(), state=SoftFileLock(self._paths.state, timeout=-1), ) self._readers_dir_fd: int | None = None self._readers_dir_fd_token: int | None = None self._hold: _Hold | None = None self._closed: bool = False with _ALL_INSTANCES_LOCK: _ALL_INSTANCES[id(self)] = self _register_fork_object(self) @classmethod def _reset_class_after_fork(cls) -> None: # pragma: forked child global _ALL_INSTANCES_LOCK # ruff:ignore[global-statement] # rebinds the module lock to a fresh one in the fork child _ALL_INSTANCES_LOCK = threading.Lock() cls._instances = WeakValueDictionary() cls._instances_lock = threading.RLock() _SINGLETONS_UNDER_CONSTRUCTION.clear() @contextmanager def read_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases a shared read lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self.acquire_read(timeout, blocking=blocking) try: yield finally: self.release() @contextmanager def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> Generator[None]: """ Context manager that acquires and releases an exclusive write lock. Falls back to instance defaults for *timeout* and *blocking* when ``None``. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately; ``None`` uses the instance default :raises RuntimeError: if a read lock is already held, or a write lock is held by a different thread :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ self.acquire_write(timeout, blocking=blocking) try: yield finally: self.release() def acquire_read(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire a shared read lock. If this instance already holds a read lock, the lock level is incremented (reentrant). Attempting to acquire a read lock while holding a write lock raises :class:`RuntimeError` (downgrade not allowed). On the 0→1 transition a daemon heartbeat thread is started that refreshes the reader marker's ``mtime`` every ``heartbeat_interval`` seconds so peers on other hosts do not evict the marker as stale. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; ``None`` uses the instance default :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a write lock is already held on this instance, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ return self._acquire("read", timeout, blocking=blocking) def acquire_write(self, timeout: float | None = None, *, blocking: bool | None = None) -> AcquireReturnProxy: """ Acquire an exclusive write lock. If this instance already holds a write lock from the same thread, the lock level is incremented (reentrant). Attempting to acquire a write lock while holding a read lock raises :class:`RuntimeError` (upgrade not allowed). Write locks are pinned to the acquiring thread: a different thread trying to re-enter also raises :class:`RuntimeError`. Writer acquisition runs in two phases. Phase 1 atomically claims ``.write`` via ``O_CREAT | O_EXCL``, which immediately blocks any new reader on any host. Phase 2 waits for existing readers to drain. Writer starvation is impossible: new readers see ``.write`` during phase 2 and wait behind the pending writer. :param timeout: maximum wait time in seconds, or ``None`` to use the instance default; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable; ``None`` uses the instance default :returns: a proxy that can be used as a context manager to release the lock :raises RuntimeError: if a read lock is already held, if a write lock is held by a different thread, if this instance was invalidated by :func:`os.fork`, or if :meth:`close` was called :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ return self._acquire("write", timeout, blocking=blocking) @classmethod def get_lock( cls, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, ) -> SoftReadWriteLock: """ Return the singleton :class:`SoftReadWriteLock` for *lock_file*. :param lock_file: path to the lock file; sidecar state/write/readers live next to it :param timeout: maximum wait time in seconds; ``-1`` means block indefinitely :param blocking: if ``False``, raise :class:`~filelock.Timeout` immediately when the lock is unavailable :returns: the singleton lock instance :raises ValueError: if an instance already exists for this path with different *timeout* or *blocking* values """ return cls(lock_file, timeout, blocking=blocking) def close(self) -> None: """ Release any held lock and release internal filesystem resources. Idempotent. After calling this method the instance can no longer acquire locks — subsequent acquires raise :class:`RuntimeError`. A fork-invalidated instance is closed without raising. """ if self._creator_pid != os.getpid(): # pragma: forked child return self.release(force=True) with self._locks.internal: if self._closed: return self._closed = True if self._readers_dir_fd is not None: # pragma: needs dir-fd with _fork_transition(): if self._readers_dir_fd_token is not None: # pragma: needs dir-fd _unregister_owned_descriptor(self._readers_dir_fd_token) self._readers_dir_fd_token = None fd, self._readers_dir_fd = self._readers_dir_fd, None with suppress(OSError): # pragma: needs dir-fd os.close(fd) def release(self, *, force: bool = False) -> None: """ Release one level of the current lock. When the lock level reaches zero the heartbeat thread is stopped and the held marker file is unlinked. On a fork-invalidated instance (that is, the child of a :func:`os.fork` call made while the parent held a lock) this method is a no-op so inherited ``with`` blocks can unwind cleanly in the child. :param force: if ``True``, release the lock completely regardless of the current lock level :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ if self._creator_pid != os.getpid(): # pragma: forked child return with self._locks.internal: hold = self._hold if hold is None: if force: return msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" raise RuntimeError(msg) if force: hold.level = 0 else: hold.level -= 1 if hold.level > 0: return self._hold = None # Order matters: signal → join → unlink. A late tick on a deleted marker is harmless and the # heartbeat's token check would catch a re-acquisition race, but joining first removes that race. hold.heartbeat_stop.set() hold.heartbeat_thread.join(timeout=self.heartbeat_interval + 1.0) if hold.is_reader: _unlink(hold.marker_name, dir_fd=self._readers_dir_fd) else: self._unlink_writer_marker_if_ours(hold.token) def _unlink_writer_marker_if_ours(self, token: str) -> None: # Remove the writer marker only while it still carries our token. If this holder was paused long # enough (a stop-the-world GC pause, SIGSTOP, a suspended VM) for a peer to evict the marker as # stale and claim the writer slot itself, the file now at .write is the peer's live marker; # unlinking it by path would let a second writer through and break mutual exclusion. The state lock # serializes this against a concurrent break/claim, and the heartbeat is already stopped, so the # token we read is authoritative. Mirrors the token re-check the stale-break path already does. with self._locks.state: if (read := _read_marker(self._paths.write)) is None: return info, _ = read if info is None or not hmac.compare_digest(info.token, token): return _unlink(self._paths.write) def _acquire( self, mode: _Mode, timeout: float | None, *, blocking: bool | None, ) -> AcquireReturnProxy: if self._creator_pid != os.getpid(): # pragma: forked child msg = f"SoftReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" raise RuntimeError(msg) timeout = self.timeout if timeout is None else timeout blocking = self.blocking if blocking is None else blocking with self._locks.internal: if self._closed: msg = f"SoftReadWriteLock on {self.lock_file} has been closed" raise RuntimeError(msg) if self._hold is not None: return self._validate_reentrant(mode) start = time.perf_counter() if not blocking: acquired = self._locks.transaction.acquire(blocking=False) elif timeout == -1: acquired = self._locks.transaction.acquire(blocking=True) else: acquired = self._locks.transaction.acquire(blocking=True, timeout=timeout) if not acquired: raise Timeout(self.lock_file) from None try: return self._do_acquire_inner(mode, timeout, start, blocking=blocking) finally: self._locks.transaction.release() def _do_acquire_inner( self, mode: _Mode, effective_timeout: float, start: float, *, blocking: bool, ) -> AcquireReturnProxy: with self._locks.internal: if self._hold is not None: return self._validate_reentrant(mode) deadline = None if effective_timeout == -1 else start + effective_timeout token = secrets.token_hex(16) if mode == "write": marker_name, is_reader = self._acquire_writer_slot(token, deadline=deadline, blocking=blocking) else: marker_name, is_reader = self._acquire_reader_slot(token, deadline=deadline, blocking=blocking) stop_event = threading.Event() heartbeat = _HeartbeatThread( refresh=self._refresh_marker, interval=self.heartbeat_interval, stop_event=stop_event, name=f"filelock-heartbeat-{id(self):x}", ) # Publish the hold and start its heartbeat under one internal-lock section, so a concurrent release() never # observes a hold whose thread has not started and joins it. If the OS refuses the thread, clear the hold and # unlink the marker we claimed: left in place, a peer evicts it as stale and acquires while this instance still # believes it holds the lock. start_error: BaseException | None = None with self._locks.internal: self._hold = _Hold( level=1, mode=mode, write_thread_id=threading.get_ident() if mode == "write" else None, marker_name=marker_name, is_reader=is_reader, token=token, heartbeat_thread=heartbeat, heartbeat_stop=stop_event, ) try: heartbeat.start() except BaseException as error: # ruff:ignore[blind-except] # clear the slot below and re-raise self._hold = None start_error = error if start_error is not None: if is_reader: _unlink(marker_name, dir_fd=self._readers_dir_fd) else: self._unlink_writer_marker_if_ours(token) raise start_error return AcquireReturnProxy(lock=self) def _validate_reentrant(self, mode: _Mode) -> AcquireReturnProxy: hold = self._hold assert hold is not None # ruff:ignore[assert] # callers dispatch here only inside the self._hold is not None branch if hold.mode != mode: opposite = "write" if mode == "read" else "read" direction = "downgrade" if mode == "read" else "upgrade" msg = ( f"Cannot acquire {mode} lock on {self.lock_file} (lock id: {id(self)}): " f"already holding a {opposite} lock ({direction} not allowed)" ) raise RuntimeError(msg) if mode == "write" and (cur := threading.get_ident()) != hold.write_thread_id: msg = ( f"Cannot acquire write lock on {self.lock_file} (lock id: {id(self)}) " f"from thread {cur} while it is held by thread {hold.write_thread_id}" ) raise RuntimeError(msg) hold.level += 1 return AcquireReturnProxy(lock=self) def _acquire_writer_slot( self, token: str, *, deadline: float | None, blocking: bool, ) -> tuple[str, bool]: # Phase 2 scans readers/ via dirfd (where supported), so we need it open even though writers never # create files inside. self._open_readers_dir() def try_claim_writer() -> bool: with self._locks.state: return self._claim_writer_marker(token) def readers_drained_touching() -> bool: with self._locks.state: # A peer may replace an expired marker while this process pauses. Refresh only our token; touching a # successor's marker would let this acquisition proceed without owning the writer slot. if not self._touch_writer_marker_if_ours(token) and not self._claim_writer_marker(token): return False self._break_stale_readers(time.time()) return not self._any_readers() self._wait_for(try_claim_writer, deadline=deadline, blocking=blocking) try: self._wait_for(readers_drained_touching, deadline=deadline, blocking=blocking) except Timeout: # Give up our writer claim so readers can make progress again, but only while the marker is # still ours: a peer may have evicted it as stale and claimed the slot while phase 2 waited. self._unlink_writer_marker_if_ours(token) raise return self._paths.write, False def _claim_writer_marker(self, token: str) -> bool: # Claim the writer slot for ``token``. Must be called holding ``self._locks.state``. Evicts a # stale marker first, then refuses to claim while a live ``.write`` exists so a peer holding the # slot is waited out instead of overwritten. _break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time()) if _file_exists(self._paths.write): return False try: _atomic_create_marker(self._paths.write, token) except FileExistsError: return False return True def _touch_writer_marker_if_ours(self, token: str) -> bool: # Refresh the writer marker through a single O_NOFOLLOW fd, but only while it still carries our # token. Returns False when the marker is gone or now belongs to a peer that reclaimed the slot, # so the caller can re-claim rather than keep a stranger's marker alive. Mirrors _refresh_marker. fd = _open_marker(self._paths.write) if fd is None: return False try: try: data = os.read(fd, _MAX_MARKER_SIZE + 1) except OSError: # pragma: no cover - e.g. EAGAIN from a hostile FIFO that has a writer attached return False info = _parse_marker_bytes(data) if info is None or not hmac.compare_digest(info.token, token): return False with suppress(OSError): touch(self._paths.write, fd=fd) return True finally: os.close(fd) def _acquire_reader_slot( self, token: str, *, deadline: float | None, blocking: bool, ) -> tuple[str, bool]: self._open_readers_dir() reader_name = f"{uuid.uuid4().hex}.{os.getpid()}" dir_fd = self._readers_dir_fd full_reader_path = str(Path(self._paths.readers) / reader_name) def try_claim_reader() -> bool: with self._locks.state: _break_stale_marker(self._paths.write, stale_threshold=self.stale_threshold, now=time.time()) if _file_exists(self._paths.write): return False if dir_fd is not None: # pragma: needs dir-fd _atomic_create_marker(reader_name, token, dir_fd=dir_fd) else: # pragma: win32 cover _atomic_create_marker(full_reader_path, token) return True self._wait_for(try_claim_reader, deadline=deadline, blocking=blocking) return (reader_name if dir_fd is not None else full_reader_path), True def _wait_for( self, predicate: Callable[[], bool], *, deadline: float | None, blocking: bool, ) -> None: while True: if predicate(): return now = time.perf_counter() if not blocking: raise Timeout(self.lock_file) if deadline is not None and now >= deadline: raise Timeout(self.lock_file) sleep_for = self.poll_interval if deadline is not None: sleep_for = min(sleep_for, max(deadline - now, 0.0)) time.sleep(sleep_for) def _open_readers_dir(self) -> None: readers_path = Path(self._paths.readers) with suppress(FileExistsError): readers_path.mkdir(mode=0o700) # mkdir has no O_NOFOLLOW, so verify via lstat that we did not land on an attacker-placed symlink # or a regular file before we open or scan inside. st = os.lstat(self._paths.readers) if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode): msg = f"{self._paths.readers} exists but is not a directory or is a symlink; refusing to use it" raise RuntimeError(msg) if self._readers_dir_fd is None and _SUPPORTS_DIR_FD: # pragma: needs dir-fd with _fork_transition(): fd = os.open(self._paths.readers, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _O_NOFOLLOW) try: token = _register_owned_descriptor(fd) except BaseException as registration_error: try: os.close(fd) except BaseException as close_error: # ruff:ignore[blind-except] # both errors surface via the group below _raise_grouped_errors( "reader directory registration and descriptor close both failed", registration_error, close_error, ) raise self._readers_dir_fd = fd self._readers_dir_fd_token = token def _any_readers(self) -> bool: with closing(self._iter_reader_entries()) as entries: for _ in entries: return True return False def _iter_reader_entries(self) -> Generator[tuple[str, bool]]: """ Yield ``(name, dirfd_relative)`` pairs for every live reader marker. ``dirfd_relative`` is ``True`` when *name* should be passed to ``dir_fd=``-aware syscalls; ``False`` when *name* is a full path because dirfd-relative I/O is unavailable on this platform. A consumer that stops early must close this generator: while suspended it holds the ``scandir`` handle open, and leaving that to the collector surfaces as an unraisable exception inside whatever runs next. """ if self._readers_dir_fd is not None: # pragma: needs dir-fd with os.scandir(self._readers_dir_fd) as it: for entry in it: if not _is_housekeeping_name(entry.name): yield entry.name, True return readers_path = Path(self._paths.readers) # pragma: win32 cover with os.scandir(readers_path) as it: # pragma: win32 cover for entry in it: # pragma: win32 cover if not _is_housekeeping_name(entry.name): # pragma: win32 cover yield str(readers_path / entry.name), False # pragma: win32 cover def _break_stale_readers(self, now: float) -> None: names: list[tuple[str, int | None]] = [] try: with closing(self._iter_reader_entries()) as entries: for name, dirfd_relative in entries: names.append((name, self._readers_dir_fd if dirfd_relative else None)) except OSError: # pragma: no cover - transient NFS scandir hiccup return for name, fd in names: _break_stale_marker(name, stale_threshold=self.stale_threshold, now=now, dir_fd=fd) def _refresh_marker(self) -> bool: with self._locks.internal: hold = self._hold if hold is None: # pragma: no cover - race between stop_event.set and join return False marker_name = hold.marker_name token = hold.token dir_fd = self._readers_dir_fd if hold.is_reader else None # Open once with O_NOFOLLOW and touch that exact descriptor. Refreshing through the verified fd # (instead of re-opening by name) closes the window where a peer unlinks our marker and drops a symlink # or a different file at the path between the read and the touch: utime then lands on the inode we # verified, or nowhere. Only an unambiguous loss stops the heartbeat: the marker gone, or a peer's token # in its place. A transient filesystem error (ESTALE / EIO on the NFS-style filesystems this lock targets) # keeps the heartbeat alive to retry next tick, the way the touch below already does, so one blip does not # silently drop a held lock. try: fd = _open_marker_fd(marker_name, dir_fd=dir_fd) except FileNotFoundError: return False except OSError: return True try: try: data = _read_marker_fd(fd) except OSError: # a transient read error or EAGAIN from a hostile FIFO; retry rather than drop the lock return True info = _parse_marker_bytes(data) # Token mismatch means another process already evicted our marker and created its own; stop the # thread so it does not keep a stranger's file alive. if info is None or not hmac.compare_digest(info.token, token): return False # A transient touch failure (ESTALE / EIO on the NFS-style filesystems this lock targets) must not # kill the heartbeat thread: the read above just confirmed the marker is still ours, so swallow the # error and retry on the next tick rather than letting the lease lapse while we still hold the lock. with suppress(OSError): touch(marker_name, fd=fd) return True finally: os.close(fd) def _reset_after_fork_in_child(self) -> None: # pragma: forked child self._locks = _Locks( internal=threading.Lock(), transaction=threading.Lock(), state=self._locks.state, ) self._hold = None self._readers_dir_fd = None self._readers_dir_fd_token = None class _HeartbeatThread(threading.Thread): def __init__( self, refresh: Callable[[], bool], interval: float, stop_event: threading.Event, name: str, ) -> None: super().__init__(name=name, daemon=True) self._refresh = refresh self._interval = interval self._stop_event = stop_event def run(self) -> None: while not self._stop_event.wait(self._interval): if not self._refresh(): self._stop_event.set() return def _read_marker(name: str, *, dir_fd: int | None = None) -> tuple[_MarkerInfo | None, float] | None: fd = _open_marker(name, dir_fd=dir_fd) if fd is None: return None try: st = os.fstat(fd) # A legitimate marker is a regular file, so anything else at the path (a FIFO, say) is reported as a # malformed marker (its mtime still drives stale eviction) without being read. Reading is where # platforms diverge: an empty non-blocking read yields 0 bytes on Linux/macOS but EAGAIN on FreeBSD, # and the EAGAIN used to abort the stale-break and wedge the acquire until timeout (#587). if not stat.S_ISREG(st.st_mode): # pragma: needs fifo return None, st.st_mtime data = os.read(fd, _MAX_MARKER_SIZE + 1) except OSError: # pragma: no cover - marker vanished or turned unreadable between open and read return None finally: os.close(fd) return _parse_marker_bytes(data), st.st_mtime def _read_marker_fd(fd: int) -> bytes: return os.read(fd, _MAX_MARKER_SIZE + 1) def _open_marker_fd(name: str, *, dir_fd: int | None = None) -> int: # The file is ours; these guard a hostile mid-flight swap. O_NOFOLLOW rejects a symlink; O_NONBLOCK keeps # a real FIFO from blocking the open forever, so it reads as a malformed marker instead of wedging a peer # that holds the state lock. flags = os.O_RDONLY | _O_NOFOLLOW | _O_NONBLOCK return os.open(name, flags, dir_fd=dir_fd) if _SUPPORTS_DIR_FD and dir_fd is not None else os.open(name, flags) def _open_marker(name: str, *, dir_fd: int | None = None) -> int | None: try: return _open_marker_fd(name, dir_fd=dir_fd) except OSError: return None def _parse_marker_bytes(data: bytes) -> _MarkerInfo | None: # Trust nothing about attacker-controlled markers; any deviation returns None so callers fall through # to stale cleanup. ``re.match`` caches compiled patterns internally, so the regex is built only once # despite being defined inline. if not data or len(data) > _MAX_MARKER_SIZE: return None try: text = data.decode("ascii") except UnicodeDecodeError: return None match = re.match( r""" \A # start of string (?P [0-9a-f]{32} ) \n # 128-bit hex token (?P [1-9][0-9]{0,9} ) \n # decimal pid: no leading zero, ≤ 10 digits (?P [\x21-\x7e]{1,253}) # printable non-whitespace ASCII (RFC 1123 hostname limit) \n* # tolerate sloppy writers that append extra newlines \Z # end of string """, text, re.VERBOSE, ) if match is None: return None pid = int(match["pid"], 10) if pid > 2**31 - 1: return None return _MarkerInfo(token=match["token"], pid=pid, hostname=match["hostname"]) def _unlink(name: str, *, dir_fd: int | None = None) -> None: with suppress(FileNotFoundError): if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd # Path.unlink has no dir_fd support, so we stay on os.unlink for the dirfd path. os.unlink(name, dir_fd=dir_fd) else: Path(name).unlink() def _break_stale_marker( # ruff:ignore[too-many-return-statements] # each return is a distinct abort/commit point in the break protocol name: str, *, stale_threshold: float, now: float, dir_fd: int | None = None, ) -> bool: # Atomic break pattern: read → rename to unique break-name → re-verify → unlink. The rename gives us a # private name nobody else can touch; if the re-verify sees a newer mtime or a different token, the # legitimate holder's heartbeat fired between read and rename and we must abort (leaving the .break.* # file behind rather than rollback-renaming, because rollback is itself racy). if (read_result := _read_marker(name, dir_fd=dir_fd)) is None: return False info_before, mtime_before = read_result if now - mtime_before <= stale_threshold: return False if info_before is None: _unlink(name, dir_fd=dir_fd) return True break_name = f"{name}{_BREAK_SUFFIX}.{os.getpid()}.{secrets.token_hex(16)}" try: if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd os.rename(name, break_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) else: Path(name).rename(break_name) except OSError: # pragma: no cover - race where the marker vanishes between read and rename return False read_after = _read_marker(break_name, dir_fd=dir_fd) if read_after is None: # pragma: no cover - race where a peer unlinks the break-name file return False info_after, mtime_after = read_after if info_after is None: # pragma: no cover - content replaced post-rename by a racing peer _unlink(break_name, dir_fd=dir_fd) return True if not hmac.compare_digest(info_before.token, info_after.token): # pragma: no cover - race only return False if mtime_after > mtime_before: # pragma: no cover - heartbeat raced our rename return False _unlink(break_name, dir_fd=dir_fd) return True def _atomic_create_marker(name: str, token: str, *, dir_fd: int | None = None) -> None: # O_NOFOLLOW blocks the symlink-overwrite attack where an attacker pre-creates the marker path as a # symlink pointing at a victim file. Mode 0o600 keeps the token unreadable to other users. flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | _O_NOFOLLOW if _SUPPORTS_DIR_FD and dir_fd is not None: # pragma: needs dir-fd fd = os.open(name, flags, 0o600, dir_fd=dir_fd) else: fd = os.open(name, flags, 0o600) # Write the whole record before the marker counts as created. On failure remove it only while the path still names # the file we opened, so a rollback never deletes a marker a concurrent reader recreated at this name. identity: tuple[int, int] | None = None try: st = os.fstat(fd) identity = st.st_dev, st.st_ino write_all(fd, f"{token}\n{os.getpid()}\n{socket.gethostname()}\n".encode("ascii")) except BaseException: os.close(fd) if identity is not None and _same_file(name, identity, dir_fd=dir_fd): _unlink(name, dir_fd=dir_fd) raise else: os.close(fd) def _same_file(name: str, identity: tuple[int, int], *, dir_fd: int | None) -> bool: try: st = os.lstat(name, dir_fd=dir_fd) if _SUPPORTS_DIR_FD and dir_fd is not None else os.lstat(name) except OSError: return False return (st.st_dev, st.st_ino) == identity def _file_exists(path: str) -> bool: try: st = os.lstat(path) except FileNotFoundError: return False return stat.S_ISREG(st.st_mode) def _is_housekeeping_name(name: str) -> bool: return name.startswith(".") or _BREAK_SUFFIX in name @dataclass(frozen=True) class _Paths: state: str write: str readers: str @dataclass class _Locks: internal: threading.Lock transaction: threading.Lock state: SoftFileLock @dataclass(frozen=True) class _MarkerInfo: token: str pid: int hostname: str @dataclass class _Hold: """Everything that exists only while a lock is held; ``None`` when the instance has no lock.""" level: int mode: _Mode write_thread_id: int | None marker_name: str is_reader: bool token: str heartbeat_thread: _HeartbeatThread heartbeat_stop: threading.Event def _cleanup_all_instances() -> None: # pragma: no cover - runs from atexit at interpreter shutdown for instance in list(_ALL_INSTANCES.values()): with suppress(Exception): instance.release(force=True) atexit.register(_cleanup_all_instances) _register_fork_class(SoftReadWriteLock) __all__ = [ "SoftReadWriteLock", ] filelock-3.32.2/tasks/benchmark.py0000644000000000000000000001723513615410400013752 0ustar00"""Measure the filelock performance matrix and print it as a report. The numbers establish a baseline rather than gate a build: run it before and after a change to compare medians and tail latency, the way :issue:`642` describes. Run with ``python tasks/benchmark.py`` or ``tox -e bench``. """ from __future__ import annotations import asyncio import os import platform import statistics import sys import time from contextlib import suppress from dataclasses import dataclass from multiprocessing import Event, Process from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final import filelock from filelock import AsyncFileLock, FileLock, SoftFileLease, SoftFileLock, StrictSoftFileLock if TYPE_CHECKING: from collections.abc import Callable, Iterator from multiprocessing.synchronize import Event as EventType _ITERATIONS: Final[int] = 200 _WARMUP: Final[int] = 20 _CONTENTION_PROCESSES: Final[int] = 8 _CONTENTION_ACQUISITIONS: Final[int] = 500 @dataclass(frozen=True) class Sample: """One measured metric, in the unit named by ``unit``.""" name: str median: float p95: float unit: str def main() -> None: print(_environment()) samples: list[Sample] = [] with TemporaryDirectory(prefix="filelock-bench-") as directory: root = Path(directory) samples += _construction(root) samples += _uncontended(root) samples.append(_timeout_cpu(root)) samples.append(_descriptor_growth(root)) samples += _cancellation_latency(root) samples += _contention(root) _report(samples) def _environment() -> str: gil = getattr(sys, "_is_gil_enabled", lambda: True)() build = "gil" if gil else "free-threaded" return ( f"filelock {filelock.__version__} | {platform.python_implementation()} {platform.python_version()} ({build}) | " f"{platform.system()} {platform.machine()} | {os.cpu_count()} cpus" ) def _backends(root: Path) -> Iterator[tuple[str, Callable[[], filelock.BaseFileLock]]]: # Each entry builds a fresh lock on its own path so no two cases contend. ReadWriteLock is soft-optional, so it is # measured separately where it applies rather than through this exclusive-lock set. yield "FileLock", lambda: FileLock(str(root / "file.lock")) yield "SoftFileLock", lambda: SoftFileLock(str(root / "soft.lock")) yield "StrictSoftFileLock", lambda: StrictSoftFileLock(str(root / "strict.lock")) yield "SoftFileLease", lambda: SoftFileLease(str(root / "lease.lock"), lease_duration=30) def _construction(root: Path) -> list[Sample]: return [_timed(f"construct {name}", build, unit="us", scale=1e6) for name, build in _backends(root)] def _uncontended(root: Path) -> list[Sample]: samples: list[Sample] = [] for name, build in _backends(root): lock = build() def acquire_release(held: filelock.BaseFileLock = lock) -> None: held.acquire() held.release() samples.append(_timed(f"acquire+release {name}", acquire_release, unit="us", scale=1e6)) return samples def _timeout_cpu(root: Path) -> Sample: # A contended acquire with a zero poll interval should spin on the clock, not the CPU. Hold the lock, time out a # second acquirer, and report the CPU time the wait burned; a busy loop would show wall-sized CPU here. holder = FileLock(str(root / "timeout.lock")) holder.acquire() waiter = FileLock(str(root / "timeout.lock")) try: started = time.process_time() with suppress(filelock.Timeout): waiter.acquire(timeout=0.25, poll_interval=0.0) cpu = time.process_time() - started finally: holder.release() return Sample("timeout cpu (0.25s wall, poll=0)", cpu * 1e3, cpu * 1e3, "ms cpu") def _descriptor_growth(root: Path) -> Sample: # Acquire and release many times; a descriptor leak would show as a rising open-descriptor count. lock = FileLock(str(root / "fd.lock")) before = _open_descriptors() for _ in range(100): lock.acquire() lock.release() grew = _open_descriptors() - before return Sample("descriptor growth over 100 cycles", float(grew), float(grew), "fds") def _cancellation_latency(root: Path) -> list[Sample]: # Hold the lock from another process so the async waiter blocks on the OS lock rather than on a peer instance in the # same task, which the deadlock guard would refuse. Then time canceling the awaiting task to the acquire unwinding. path = str(root / "cancel.lock") acquired, release = Event(), Event() holder = Process(target=_hold_until, args=(path, acquired, release)) holder.start() async def measure() -> float: waiter = AsyncFileLock(path) task = asyncio.ensure_future(waiter.acquire(poll_interval=0.01)) await asyncio.sleep(0.02) started = time.perf_counter() task.cancel() with suppress(asyncio.CancelledError): await task return time.perf_counter() - started try: acquired.wait(timeout=5) latencies = [asyncio.run(measure()) for _ in range(20)] finally: release.set() holder.join(timeout=5) return [ Sample( "async acquire cancellation latency", statistics.median(latencies) * 1e3, _percentile(latencies, 95) * 1e3, "ms", ) ] def _hold_until(path: str, acquired: EventType, release: EventType) -> None: lock = FileLock(path) lock.acquire() acquired.set() release.wait(timeout=30) lock.release() def _contention(root: Path) -> list[Sample]: samples: list[Sample] = [] for name, path in (("FileLock", root / "c-file.lock"), ("StrictSoftFileLock", root / "c-strict.lock")): started = time.perf_counter() workers = [Process(target=_hammer, args=(name, str(path))) for _ in range(_CONTENTION_PROCESSES)] for worker in workers: worker.start() for worker in workers: worker.join() elapsed = time.perf_counter() - started total = _CONTENTION_PROCESSES * _CONTENTION_ACQUISITIONS per = elapsed * 1e3 / total samples.append(Sample(f"contention {name} ({total} acquisitions)", per, per, "ms/acq")) return samples def _hammer(backend: str, path: str) -> None: lock = FileLock(path) if backend == "FileLock" else StrictSoftFileLock(path) for _ in range(_CONTENTION_ACQUISITIONS): lock.acquire() lock.release() def _timed(name: str, run: Callable[[], object], *, unit: str, scale: float) -> Sample: for _ in range(_WARMUP): run() durations = [_one(run) for _ in range(_ITERATIONS)] return Sample(name, statistics.median(durations) * scale, _percentile(durations, 95) * scale, unit) def _one(run: Callable[[], object]) -> float: started = time.perf_counter() run() return time.perf_counter() - started def _percentile(values: list[float], percentile: int) -> float: ordered = sorted(values) index = min(len(ordered) - 1, round(percentile / 100 * len(ordered))) return ordered[index] def _open_descriptors() -> int: for directory in ("/proc/self/fd", "/dev/fd"): with suppress(OSError): return sum(1 for _ in Path(directory).iterdir()) return 0 # pragma: no cover # neither procfs nor /dev/fd exists on this platform def _report(samples: list[Sample]) -> None: width = max(len(sample.name) for sample in samples) print(f"\n{'metric':<{width}} {'median':>12} {'p95':>12} unit") print("-" * (width + 34)) for sample in samples: print(f"{sample.name:<{width}} {sample.median:>12.3f} {sample.p95:>12.3f} {sample.unit}") if __name__ == "__main__": main() filelock-3.32.2/tasks/capabilities.py0000644000000000000000000002023513615410400014443 0ustar00"""What the runtime this suite is running on can actually do. Probed rather than inferred from a platform or interpreter name: a name answers who is running, and every question here is about what the runtime can do. The coverage pragmas and the tests' skipif gates both read these, so a test cannot skip while coverage still demands its lines. Kept apart from the pragma plugin so reading a capability never requires coverage to be installed. """ from __future__ import annotations import gc import os import signal import socket import sys import tempfile import weakref from asyncio import CancelledError from contextlib import asynccontextmanager, contextmanager from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from collections.abc import AsyncIterator, Generator, Iterator def _supports_symlink() -> bool: # Windows grants this per privilege, not per platform, so ask the filesystem. with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() try: Path(directory, "link").symlink_to(target) except (OSError, NotImplementedError, AttributeError): return False return True def _supports_unlinking_an_open_file() -> bool: # Where this is refused, a peer can never take a live holder's marker. with tempfile.TemporaryDirectory() as directory: victim = Path(directory, "victim") victim.touch() with victim.open("rb"): try: victim.unlink() except OSError: return False return True def _honors_link_follow_symlinks() -> bool: # PyPy advertises the option then rejects it with EINVAL, so trust a real link over os.supports_follow_symlinks. if not hasattr(os, "link"): return False with tempfile.TemporaryDirectory() as directory: source = Path(directory, "source") source.touch() try: os.link(source, Path(directory, "link"), follow_symlinks=False) except (OSError, NotImplementedError, ValueError): return False return True def _finalizes_on_last_reference() -> bool: # Only a refcounting collector runs __del__ the moment the last reference goes; a tracing one defers it. finalized: list[bool] = [] class Probe: def __del__(self) -> None: finalized.append(True) Probe() return bool(finalized) def _finalizes_on_collection() -> bool: # GraalPy queues finalizers on the host collector, so even a forced collection does not run __del__. finalized: list[bool] = [] class Probe: def __del__(self) -> None: finalized.append(True) Probe() gc.collect() return bool(finalized) def _collects_classes() -> bool: # A dynamically built lock subclass must not outlive its last reference, or the registries keyed on it leak. def build() -> type: class Probe: pass return Probe reference = weakref.ref(build()) gc.collect() return reference() is None def _preserves_context_thrown_into_a_generator() -> bool: # GraalPy resets __context__ when contextlib throws into the suspended generator, losing the chained cause. @contextmanager def probe() -> Iterator[None]: yield error = KeyError("thrown") error.__context__ = ValueError("context") try: with probe(): raise error except KeyError as caught: return caught.__context__ is not None return False # pragma: no cover # the raise above always propagates class _Suspend: """An awaitable that parks its coroutine once, so a probe can throw into a suspended frame without a loop.""" def __await__(self) -> Generator[None, None, None]: yield def _propagates_a_cancellation_thrown_into_a_coroutine() -> bool: # Driven by hand so the probe needs no event loop: GraalPy answers athrow with RuntimeError instead of the # CancelledError, so every cancellation crossing an async context manager surfaces as the wrong exception. @asynccontextmanager async def gate() -> AsyncIterator[None]: await _Suspend() yield async def body() -> None: async with gate(): pass coroutine = body() coroutine.send(None) try: coroutine.throw(CancelledError()) except CancelledError: return True except BaseException: # ruff:ignore[blind-except] # whatever else a runtime substitutes counts as the deviation return False return False # pragma: no cover # throwing into the suspended coroutine always raises _AUDIT_PROBE_EVENT: Final[str] = "filelock.capability-probe" def _delivers_audit_events() -> bool: # GraalPy accepts a hook and never calls it. The hook is a permanent no-op; tests install their own anyway. delivered: list[bool] = [] sys.addaudithook(lambda event, _args: delivered.append(True) if event == _AUDIT_PROBE_EVENT else None) sys.audit(_AUDIT_PROBE_EVENT) return bool(delivered) def _refuses_to_open_a_symlink() -> bool: # GraalPy accepts O_NOFOLLOW then follows the link anyway, so ask for the refusal rather than the constant. if not hasattr(os, "O_NOFOLLOW"): return False if not _supports_symlink(): # Nothing to point the probe at, so keep the constant's answer rather than reporting a gap we cannot see. return True with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() link = Path(directory, "link") link.symlink_to(target) try: descriptor = os.open(link, os.O_RDONLY | os.O_NOFOLLOW) except OSError: return True os.close(descriptor) return False def _enforces_file_mode() -> bool: # Without POSIX permission bits a chmod does not read back. with tempfile.TemporaryDirectory() as directory: probe = Path(directory, "probe") probe.touch() probe.chmod(_OWNER_READ_WRITE) return probe.stat().st_mode & 0o777 == _OWNER_READ_WRITE _OWNER_READ_WRITE: Final[int] = 0o600 #: Capability -> whether this runtime provides it. Tests gate their skipif on this same mapping. CAPABILITIES: Final[dict[str, bool]] = { "fork": hasattr(os, "fork") and hasattr(os, "register_at_fork"), # Narrower than "fork": GraalPy registers fork handlers but cannot fork. "register-at-fork": hasattr(os, "register_at_fork"), "dir-fd": os.open in os.supports_dir_fd, # Narrower than "dir-fd": GraalPy takes os.open relative to a directory descriptor but not os.link. "link-dir-fd": hasattr(os, "link") and os.link in os.supports_dir_fd, "fork1": hasattr(os, "fork1"), "hard-link": hasattr(os, "link"), "symlink": _supports_symlink(), "fcntl": find_spec("fcntl") is not None, "unlink-open-file": _supports_unlinking_an_open_file(), "posix-signals": hasattr(signal, "SIGKILL"), "file-mode": _enforces_file_mode(), "prompt-finalization": _finalizes_on_last_reference(), "collected-finalization": _finalizes_on_collection(), "class-collection": _collects_classes(), "generator-exception-context": _preserves_context_thrown_into_a_generator(), "coroutine-cancellation": _propagates_a_cancellation_thrown_into_a_coroutine(), "audit-events": _delivers_audit_events(), "fd-directory": any(Path(view).is_dir() for view in ("/dev/fd", "/proc/self/fd")), "fifo": hasattr(os, "mkfifo"), "af-unix": hasattr(socket, "AF_UNIX"), # Distinct from "symlink": a runtime can create them yet still not refuse to follow one. "o-nofollow": _refuses_to_open_a_symlink(), "utime-nofollow": os.utime in os.supports_follow_symlinks, "utime-fd": os.utime in os.supports_fd, "sqlite3": find_spec("sqlite3") is not None, # A source consumer may run the suite unmeasured, and a forked child then has nothing to flush. "coverage": find_spec("coverage") is not None, "link-follow-symlinks": _honors_link_follow_symlinks(), # Only the tox env that installs a released filelock sets this. "old-client": bool(os.environ.get("FILELOCK_OLD_CLIENT_PATH")), } __all__ = [ "CAPABILITIES", ] filelock-3.32.2/tasks/coverage_pragmas.py0000644000000000000000000000643413615410400015324 0ustar00"""Coverage exclusions keyed on the capability that code needs. covdefaults keys its pragmas on ``os.name``, ``sys.platform`` and ``sys.implementation.name``, so code that cannot run for a capability reason has to name a platform as a stand-in. The stand-in goes stale. A ``win32 no cover`` sat on ``os.link``'s follow_symlinks fallback and demanded a branch modern Windows never takes, and the same guess hid real Windows gaps behind platform-agnostic code. ``# pragma: needs `` drops out only where the capability is absent, and ``# pragma: lacks `` only where it is present. Both read the probes in :mod:`capabilities`, as do the tests' skipif gates, so a test cannot skip while coverage still demands its lines. List this after covdefaults in ``[tool.coverage] run.plugins``; both merge into the same options. """ from __future__ import annotations from typing import TYPE_CHECKING, Final, cast from capabilities import CAPABILITIES from coverage import CoveragePlugin if TYPE_CHECKING: from collections.abc import Iterable from coverage.plugin_support import Plugins from coverage.types import TConfigurable # coverage passes the config's plugin options; this plugin takes none. def coverage_init(reg: Plugins, options: dict[str, str]) -> None: # ruff:ignore[unused-function-argument] reg.add_configurer(CapabilityPragmas()) class CapabilityPragmas(CoveragePlugin): """Exclude ``needs``/``lacks`` capability pragmas on the runtimes where that code cannot run.""" def configure(self, config: TConfigurable) -> None: excluded = [ rf"# pragma: {'lacks' if present else 'needs'} {name}\b" for name, present in sorted(CAPABILITIES.items()) ] self._extend(config, "report:exclude_lines", [*excluded, *_ALWAYS_EXCLUDED]) # A guarded clause header only ever takes one arc, as covdefaults already assumes for its own pragmas. self._extend(config, "report:partial_branches", [_CAPABILITY_PRAGMA, *_ALWAYS_EXCLUDED]) unrunnable = [ pattern for name, patterns in sorted(_CAPABILITY_MODULES.items()) if not CAPABILITIES[name] for pattern in patterns ] if unrunnable: self._extend(config, "report:omit", unrunnable) @staticmethod def _extend(config: TConfigurable, option: str, patterns: list[str]) -> None: # get_option is typed for every coverage option; these two always hold regexes. merged: set[str] = set(cast("Iterable[str]", config.get_option(option) or ())) merged.update(patterns) config.set_option(option, sorted(merged)) #: Modules a missing capability makes unrunnable in full; marking every line would restate one module-level gate. _CAPABILITY_MODULES: Final[dict[str, tuple[str, ...]]] = { "hard-link": ( "*/tests/test_strict_soft*.py", "*/tests\\test_strict_soft*.py", "*/filelock/_strict.py", "*\\filelock\\_strict.py", ), } #: A forked child exits through os._exit without writing coverage data, so no job in the matrix can see these. _ALWAYS_EXCLUDED: Final[tuple[str, ...]] = (r"# pragma: forked child\b",) _CAPABILITY_PRAGMA: Final[str] = rf"# pragma: (needs|lacks) ({'|'.join(sorted(CAPABILITIES))})\b" __all__ = [ "CAPABILITIES", "coverage_init", ] filelock-3.32.2/tasks/deny_symlink.py0000644000000000000000000001503613615410400014522 0ustar00"""Run a command on Windows with symbolic-link creation denied, and report why it is or is not denied. Windows grants symlink creation through two independent paths, so closing one proves nothing. A process holding SeCreateSymbolicLinkPrivilege may always create them; separately, Developer Mode lets an unprivileged process create them through SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE. The CI job clears the Developer Mode registry value, and this script removes the privilege from its own token. SE_PRIVILEGE_REMOVED is documented as irreversible, with checks for a removed privilege returning STATUS_PRIVILEGE_NOT_HELD, which is the guarantee this relies on. The documentation does not say whether a child inherits the removal or whether clearing Developer Mode needs a reboot, so this reports what it observes rather than assuming. Read those lines in the job log before trusting the job. Usage: python deny_symlink.py [args...] """ from __future__ import annotations import ctypes import subprocess # ruff:ignore[suspicious-subprocess-import] # launching the suite is this script's whole job import sys import tempfile from ctypes import wintypes from pathlib import Path from typing import Final _TOKEN_ADJUST_PRIVILEGES: Final[int] = 0x0020 _TOKEN_QUERY: Final[int] = 0x0008 _SE_PRIVILEGE_REMOVED: Final[int] = 0x0004 _ERROR_NOT_ALL_ASSIGNED: Final[int] = 1300 _PRIVILEGE: Final[str] = "SeCreateSymbolicLinkPrivilege" class _Luid(ctypes.Structure): _fields_ = (("low_part", wintypes.DWORD), ("high_part", wintypes.LONG)) class _LuidAndAttributes(ctypes.Structure): _fields_ = (("luid", _Luid), ("attributes", wintypes.DWORD)) class _TokenPrivileges(ctypes.Structure): _fields_ = (("privilege_count", wintypes.DWORD), ("privileges", _LuidAndAttributes * 1)) def main() -> int: if sys.platform != "win32": print("deny_symlink only applies to Windows") return 1 print(f"developer mode: {_developer_mode()}") print(f"{_PRIVILEGE} before: {_privilege_state()}") print(f"symlink before: {_can_symlink()}") print(f"removal reported: {_remove_privilege()}") print(f"{_PRIVILEGE} after: {_privilege_state()}") print(f"symlink after (this process): {_can_symlink()}") child = subprocess.run( [sys.executable, "-c", _CHILD_PROBE], capture_output=True, text=True, check=False, ) print(f"symlink after (child process): {child.stdout.strip() or child.stderr.strip()}") return subprocess.run(sys.argv[1:], check=False).returncode def _developer_mode() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" import winreg # ruff:ignore[import-outside-top-level] Windows-only, and this module already refuses to run elsewhere try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" ) as key: return str(winreg.QueryValueEx(key, "AllowDevelopmentWithoutDevLicense")[0]) except OSError as error: return f"unreadable ({error})" def _privilege_state() -> str: result = subprocess.run( ["whoami", "/priv"], # ruff:ignore[start-process-with-partial-path] resolved from PATH on every Windows image capture_output=True, text=True, check=False, ) for line in result.stdout.splitlines(): if _PRIVILEGE in line: return line.strip() return "absent from the token" def _remove_privilege() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # Declare the signatures: GetCurrentProcess returns the pseudo-handle (HANDLE)-1, and ctypes' default c_int restype # truncates it on 64-bit, so OpenProcessToken is handed a bad handle and fails with ERROR_INVALID_HANDLE. kernel32.GetCurrentProcess.argtypes = () kernel32.GetCurrentProcess.restype = wintypes.HANDLE advapi32.OpenProcessToken.argtypes = (wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)) advapi32.OpenProcessToken.restype = wintypes.BOOL advapi32.LookupPrivilegeValueW.argtypes = (wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.POINTER(_Luid)) advapi32.LookupPrivilegeValueW.restype = wintypes.BOOL advapi32.AdjustTokenPrivileges.argtypes = ( wintypes.HANDLE, wintypes.BOOL, ctypes.POINTER(_TokenPrivileges), wintypes.DWORD, ctypes.c_void_p, ctypes.c_void_p, ) advapi32.AdjustTokenPrivileges.restype = wintypes.BOOL token = wintypes.HANDLE() if not advapi32.OpenProcessToken( kernel32.GetCurrentProcess(), _TOKEN_ADJUST_PRIVILEGES | _TOKEN_QUERY, ctypes.byref(token) ): return f"OpenProcessToken failed with {ctypes.get_last_error()}" luid = _Luid() if not advapi32.LookupPrivilegeValueW(None, _PRIVILEGE, ctypes.byref(luid)): return f"LookupPrivilegeValue failed with {ctypes.get_last_error()}" privileges = _TokenPrivileges(1, (_LuidAndAttributes * 1)(_LuidAndAttributes(luid, _SE_PRIVILEGE_REMOVED))) if not advapi32.AdjustTokenPrivileges(token, False, ctypes.byref(privileges), 0, None, None): # ruff:ignore[boolean-positional-value-in-call] return f"AdjustTokenPrivileges failed with {ctypes.get_last_error()}" # The call reports success even when the token never held the privilege, so read the code it leaves behind. if (code := ctypes.get_last_error()) == _ERROR_NOT_ALL_ASSIGNED: return "the token did not hold the privilege" return "removed" if code == 0 else f"unexpected code {code}" _CHILD_PROBE: Final[str] = """ import pathlib, tempfile with tempfile.TemporaryDirectory() as directory: target = pathlib.Path(directory, "target") target.touch() try: pathlib.Path(directory, "link").symlink_to(target) except OSError as error: print(f"False (WinError {error.winerror})") else: print("True") """ def _can_symlink() -> str: # ty needs the platform narrowed to see the Windows-only members from any host. assert sys.platform == "win32" with tempfile.TemporaryDirectory() as directory: target = Path(directory, "target") target.touch() try: Path(directory, "link").symlink_to(target) except OSError as error: return f"False (WinError {error.winerror})" return "True" if __name__ == "__main__": raise SystemExit(main()) filelock-3.32.2/tasks/verify_filesystem.py0000644000000000000000000002166113615410400015566 0ustar00"""Verify that filelock holds mutual exclusion on a target filesystem, and exit non-zero when it does not. Point it at a directory on the filesystem under test (an NFS or SMB mount, say) and it runs a mutual-exclusion check: several processes each take the lock a fixed number of times and record the wall-clock interval they spend holding it. A correct lock never lets two holders overlap and lets every process finish its holds, so intervals never intersect and the completed count matches the expected total. Run with ``python tasks/verify_filesystem.py [directory]``. Overlap detection rather than a shared counter is deliberate. A lost-update counter conflates lock exclusion with data cache coherence: two independent NFS client caches can lose an update to a counter even under a perfectly exclusive lock, because a read-modify-write reads a stale cached copy. Each process here only returns its own intervals (no shared mutable state read across caches), so the check measures the lock and nothing else. CLOCK_MONOTONIC is system-wide on one host, so intervals from sibling processes are directly comparable. The contention is deliberately moderate and dispersed so the result is deterministic: a small process count, a short hold, and a randomized gap between holds keep any contender from being starved out, and the genuinely transient NFS errors (a stale handle or a momentary permission race under concurrent create and unlink, which robust NFS clients retry) are retried rather than treated as failures. A correct lock then finishes every hold on every run. Every lock type is always run and printed, so the output records the real behavior of each on the filesystem. FILELOCK_VERIFY_LOCKS narrows only which lock types failing turns the exit code non-zero: a filesystem where a type is known-unsupported (the strict claim over SMB) still reports its result without failing the gate. """ from __future__ import annotations import os import random import sys import time from concurrent.futures import ProcessPoolExecutor from errno import EACCES, ENOENT, ESTALE from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Final from filelock import FileLock, SoftFileLock, StrictSoftFileLock if TYPE_CHECKING: from collections.abc import Callable _PROCESSES: Final[int] = 4 _HOLDS: Final[int] = 100 _ACQUIRE_TIMEOUT: Final[float] = 60.0 _HOLD_SECONDS: Final[float] = 0.004 _GAP_MAX_SECONDS: Final[float] = 0.008 _TRANSIENT_RETRIES: Final[int] = 16 _RETRY_BACKOFF: Final[float] = 0.01 _TRANSIENT_ERRNOS: Final[frozenset[int]] = frozenset({EACCES, ENOENT, ESTALE}) _ALL_LOCKS: Final[tuple[str, ...]] = ("FileLock", "SoftFileLock", "StrictSoftFileLock") def main() -> int: # Each argument is a mount of the same filesystem. Two independent mounts (an NFS export mounted twice with # nosharecache, say) are two client caches over one server, so contending across them is a genuine multi-client # check, not two views of one cache. With no arguments a temporary directory checks the local single-mount case. if mounts := [Path(argument) for argument in sys.argv[1:]]: for mount in mounts: mount.mkdir(parents=True, exist_ok=True) return _verify_across(mounts) with TemporaryDirectory(prefix="filelock-verify-") as directory: return _verify_across([Path(directory)]) def _verify_across(mounts: list[Path]) -> int: where = str(mounts[0]) if len(mounts) == 1 else f"{len(mounts)} mounts of {mounts[0]} .. {mounts[-1]}" print(f"verifying mutual exclusion across {where} ({_PROCESSES} processes x {_HOLDS} holds)") gated = _gated_locks() failures = 0 for name in _ALL_LOCKS: counts, overlaps, reason = _run_one(name, mounts) expected = _PROCESSES * _HOLDS # A correct lock never overlaps, finishes every hold, and raises no error the retries could not clear. ok = overlaps == 0 and sum(counts) == expected and reason is None failures += (not ok) and name in gated note = "" if name in gated else " (ungated)" tail = f"{note}{f' {reason}' if reason else ''}" stats = f"held={sum(counts)}/{expected} overlaps={overlaps}" print(f" {name:20} {'PASS' if ok else 'FAIL'} {stats}{tail}") return 1 if failures else 0 def _gated_locks() -> frozenset[str]: if not (requested := os.environ.get("FILELOCK_VERIFY_LOCKS")): return frozenset(_ALL_LOCKS) return frozenset(requested.split(",")) def _run_one(name: str, mounts: list[Path]) -> tuple[list[int], int, str | None]: # Same basename on every mount, so the mounts contend on one server file through their independent caches. lock_paths = [str(mounts[index % len(mounts)] / f"{name}.lock") for index in range(_PROCESSES)] with ProcessPoolExecutor(max_workers=_PROCESSES) as pool: results = list(pool.map(_hammer, [name] * _PROCESSES, lock_paths)) counts = [len(intervals) for intervals, _ in results] reason = next((reason for _, reason in results if reason is not None), None) return counts, _count_overlaps([intervals for intervals, _ in results]), reason def _count_overlaps(per_process: list[list[tuple[float, float]]]) -> int: # Sweep the intervals in start order: a hold that begins before the latest end seen so far, and belongs to another # process, means two holders were inside the lock at once. A correct lock hands off strictly, so nothing overlaps. intervals = sorted((enter, leave, owner) for owner, held in enumerate(per_process) for enter, leave in held) overlaps = 0 latest_leave = float("-inf") latest_owner = -1 for enter, leave, owner in intervals: if enter < latest_leave and owner != latest_owner: overlaps += 1 if leave > latest_leave: latest_leave, latest_owner = leave, owner return overlaps def _hammer(name: str, lock_path: str) -> tuple[list[tuple[float, float]], str | None]: lock = _build(name, lock_path) intervals: list[tuple[float, float]] = [] for _ in range(_HOLDS): if (reason := _resiliently(lambda: lock.acquire(timeout=_ACQUIRE_TIMEOUT))) is not None: return intervals, reason enter = time.monotonic() # Hold briefly so a broken lock lets a second holder in during an observable window; a correct lock serializes # the holds regardless. enter is stamped after acquire and leave before release, so a correct hand-off can never # look like an overlap even though release and the next acquire race. time.sleep(_HOLD_SECONDS) leave = time.monotonic() if (reason := _resiliently(lock.release)) is not None: intervals.append((enter, leave)) return intervals, reason intervals.append((enter, leave)) # A randomized gap outside the lock breaks the lock-step herd, so a poll-based lock hands off fairly and no # contender is starved out of finishing its holds. time.sleep(random.uniform(0, _GAP_MAX_SECONDS)) # ruff:ignore[suspicious-non-cryptographic-random-usage] - test dispersion, not cryptographic return intervals, None def _resiliently(action: Callable[[], object]) -> str | None: # Retry the genuinely transient NFS errors (a stale handle, a create/unlink permission race under contention) that # a robust client retries, so one hiccup never aborts a hold. A Timeout from starvation or a rejection the # filesystem always makes (EINVAL for the strict claim on CIFS) is not transient and ends this process's run. for attempt in range(_TRANSIENT_RETRIES): if (error := _attempt(action)) is None: return None if not _has_transient(error) or attempt == _TRANSIENT_RETRIES - 1: return _describe(error) time.sleep(_RETRY_BACKOFF * (attempt + 1)) return "transient errors exhausted retries" def _attempt(action: Callable[[], object]) -> BaseException | None: try: action() except Exception as error: # ruff:ignore[blind-except] - the caller classifies transient versus terminal; a harness must not die return error return None def _has_transient(error: BaseException) -> bool: # An ExceptionGroup (the strict claim wraps its cleanup failures in one) exposes leaves via ``exceptions``; recurse # rather than name BaseExceptionGroup, which is not a builtin on the 3.10 floor this script is linted against. if (leaves := getattr(error, "exceptions", None)) is not None: return any(_has_transient(leaf) for leaf in leaves) return isinstance(error, OSError) and not isinstance(error, TimeoutError) and error.errno in _TRANSIENT_ERRNOS def _describe(error: BaseException) -> str: return f"{type(error).__name__}: {error}"[:160] def _build(name: str, lock_path: str) -> FileLock | SoftFileLock | StrictSoftFileLock: if name == "FileLock": return FileLock(lock_path) if name == "SoftFileLock": return SoftFileLock(lock_path) return StrictSoftFileLock(lock_path) if __name__ == "__main__": raise SystemExit(main()) filelock-3.32.2/tasks/capability/assert_fallback.py0000644000000000000000000000162213615410400017252 0ustar00"""Confirm filelock took its documented fallback when ``FILELOCK_BLOCK_MODULE`` hid a module, or exit non-zero. The capability jobs set the module and put ``tasks/capability`` on ``PYTHONPATH`` so the sibling ``sitecustomize`` hides it; this checks the import-time consequence before the verifier exercises the resulting lock. """ from __future__ import annotations import os import filelock def main() -> None: blocked = os.environ["FILELOCK_BLOCK_MODULE"] if blocked == "fcntl" and filelock.has_fcntl: msg = f"without fcntl, expected has_fcntl False, got {filelock.has_fcntl!r}" raise SystemExit(msg) if blocked == "sqlite3" and filelock.ReadWriteLock is not None: msg = f"without sqlite3, expected ReadWriteLock None, got {filelock.ReadWriteLock!r}" raise SystemExit(msg) print(f"fallback engaged without {blocked}") if __name__ == "__main__": main() filelock-3.32.2/tasks/capability/sitecustomize.py0000644000000000000000000000146313615410400017044 0ustar00"""Hide the module named in ``FILELOCK_BLOCK_MODULE`` so a capability job can run filelock without it. Put this directory on ``PYTHONPATH`` and set ``FILELOCK_BLOCK_MODULE=fcntl`` (or ``sqlite3``); every process, including the workers a verifier spawns, then imports it and refuses that one module, so filelock takes its documented fallback. A ``None`` entry in ``sys.modules`` is CPython's marker for "known unimportable", so it forces ``ImportError`` for a module already cached at interpreter start (``fcntl`` is a builtin) as well as for a later first import (``sqlite3``). """ from __future__ import annotations import os import sys if _blocked := os.environ.get("FILELOCK_BLOCK_MODULE"): sys.modules[_blocked] = None # ty: ignore[invalid-assignment] # a None entry is the documented unimportable marker filelock-3.32.2/tasks/termux/Dockerfile0000644000000000000000000000142113615410400014752 0ustar00# Cached base image for the Termux (Android/bionic) test run: only the expensive, source-independent setup lives here, # so iterating on filelock re-runs just the copy, the local install and the tests (see compose.yaml), never this # bootstrap. termux-docker's apt refuses to run as root and only works after its bootstrap, which lives in the image # entrypoint, so the build step is dispatched through /entrypoint.sh. compose.yaml copies the source into $HOME; that # copy preserves the mount's host ownership, so git is told to trust the tree for hatch-vcs' version lookup. FROM termux/termux-docker:aarch64 RUN ["/entrypoint.sh", "bash", "-lc", "apt-get update -qq && apt-get install -yq python python-pip git && pip install -q tox && git config --system --add safe.directory '*'"] filelock-3.32.2/tests/async_filelock_cancellation_helpers.py0000644000000000000000000000453313615410400021255 0ustar00from __future__ import annotations import importlib import multiprocessing import sys import threading from typing import TYPE_CHECKING, cast from filelock import FileLock, Timeout if TYPE_CHECKING: import asyncio from multiprocessing.sharedctypes import Synchronized from typing import Protocol class FcntlModule(Protocol): LOCK_UN: int @staticmethod def flock(fd: int, operation: int) -> None: ... def assert_cancellation_message(error: asyncio.CancelledError, message: str) -> None: # Task.cancel() did not propagate its message to code awaiting the task until Python 3.11. assert error.args == ((message,) if sys.version_info >= (3, 11) else ()) def get_fcntl() -> FcntlModule: # pragma: win32 no cover return cast("FcntlModule", importlib.import_module("fcntl")) def assert_file_lock_state(lock_file: str, *, available: bool) -> None: context = multiprocessing.get_context("spawn") acquired = context.Value("b", False) probe = context.Process(target=_probe_file_lock, args=(lock_file, acquired)) probe.start() try: probe.join(timeout=5) assert not probe.is_alive(), "lock probe did not exit" assert (probe.exitcode, acquired.value) == (0, available) finally: if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails probe.terminate() probe.join(timeout=5) probe.close() def start_file_lock_holder(lock_file: str) -> tuple[threading.Thread, threading.Event, threading.Event]: holder_started = threading.Event() finish_holder = threading.Event() holder = threading.Thread(target=_hold_file_lock, args=(lock_file, holder_started, finish_holder)) holder.start() return holder, holder_started, finish_holder def _probe_file_lock(lock_file: str, acquired: Synchronized[bool]) -> None: try: with FileLock(lock_file, timeout=0): acquired.value = True except Timeout: # pragma: win32 no cover pass # pragma: win32 no cover def _hold_file_lock(lock_file: str, holder_started: threading.Event, finish_holder: threading.Event) -> None: with FileLock(lock_file): holder_started.set() assert finish_holder.wait(timeout=5) __all__ = ["assert_cancellation_message", "assert_file_lock_state", "get_fcntl", "start_file_lock_holder"] filelock-3.32.2/tests/capability_marks.py0000644000000000000000000001205213615410400015343 0ustar00"""Marks for runtime capabilities several test modules gate on. Capabilities used by a single module stay private to it, as ``_NEEDS_LINK_DIR_FD`` and friends do. These span modules, so they live here and read the same ``CAPABILITIES`` probes the coverage pragmas use. Gate on a probe rather than on an interpreter or platform name: a name answers who is running, and every one of these questions is about what the runtime can do. """ from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest from capabilities import CAPABILITIES if TYPE_CHECKING: from typing import Final #: The one gate here that asks who is running rather than what it can do. GraalPy's multiprocessing semaphores raise #: ``OSError: [Errno 0] Success`` from ``_wait_semaphore.acquire(False)`` when ``Event.set()`` notifies a contended #: condition, which fails whichever test happens to be coordinating processes at the time. The defect is in the #: interpreter's own ``multiprocessing/synchronize.py``, it only appears on Linux under load, and an uncontended probe #: cannot see it, so there is nothing to measure and no fix to make from here. SKIP_ON_UNRELIABLE_PROCESS_SYNC: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.implementation.name == "graalpy", reason="this runtime raises OSError from multiprocessing Event.set() under contention", ) NEEDS_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fork"], reason="installing fork handlers needs os.register_at_fork" ) NEEDS_REGISTER_AT_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["register-at-fork"], reason="the fork transition gate is installed through os.register_at_fork" ) NEEDS_FORK1: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fork1"], reason="forking a single thread needs the Solaris os.fork1" ) NEEDS_FCNTL: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fcntl"], reason="native flock semantics need the fcntl module" ) NEEDS_SYMLINK: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["symlink"], reason="creating a symlink needs a privilege this runtime does not grant" ) #: Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. NEEDS_PARENT_SYMLINK_COLLAPSE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["symlink"] or sys.platform == "win32", reason="a symlinked parent collapses into one key only where the parent is resolved with realpath", ) NEEDS_FILE_MODE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["file-mode"], reason="making a folder or a claim unreadable needs POSIX permission bits" ) NEEDS_UNLINK_OPEN_FILE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["unlink-open-file"], reason="this runtime keeps an open marker undeletable, so no peer can take it from a live holder", ) NEEDS_POSIX_SIGNALS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["posix-signals"], reason="liveness is probed with os.kill only where POSIX signals exist" ) NEEDS_PROMPT_FINALIZATION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["prompt-finalization"], reason="a dropped reference does not run __del__ on a deferred collector", ) NEEDS_COLLECTED_FINALIZATION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["collected-finalization"], reason="gc.collect() does not run __del__ on this runtime", ) NEEDS_CLASS_COLLECTION: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["class-collection"], reason="gc.collect() does not reclaim classes on this runtime", ) NEEDS_GENERATOR_EXCEPTION_CONTEXT: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["generator-exception-context"], reason="this runtime clears __context__ when an exception is thrown into a suspended frame", ) NEEDS_AUDIT_EVENTS: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["audit-events"], reason="this runtime never delivers audit events to an installed hook", ) #: Raised from ``_GeneratorContextManagerBase.__aexit__``, so the lock's cancellation contract cannot be observed at #: all there. Not strict: the deviation depends on where the cancellation lands, so some of these tests still pass. XFAIL_WITHOUT_COROUTINE_CANCELLATION: Final[pytest.MarkDecorator] = pytest.mark.xfail( not CAPABILITIES["coroutine-cancellation"], reason="GraalPy's contextlib answers athrow() with RuntimeError instead of propagating the CancelledError", strict=False, ) __all__ = [ "NEEDS_AUDIT_EVENTS", "NEEDS_CLASS_COLLECTION", "NEEDS_COLLECTED_FINALIZATION", "NEEDS_FCNTL", "NEEDS_FILE_MODE", "NEEDS_FORK", "NEEDS_FORK1", "NEEDS_GENERATOR_EXCEPTION_CONTEXT", "NEEDS_PARENT_SYMLINK_COLLAPSE", "NEEDS_POSIX_SIGNALS", "NEEDS_PROMPT_FINALIZATION", "NEEDS_REGISTER_AT_FORK", "NEEDS_SYMLINK", "NEEDS_UNLINK_OPEN_FILE", "SKIP_ON_UNRELIABLE_PROCESS_SYNC", "XFAIL_WITHOUT_COROUTINE_CANCELLATION", ] filelock-3.32.2/tests/conftest.py0000644000000000000000000000456513615410400013664 0ustar00from __future__ import annotations import os from pathlib import Path from typing import TYPE_CHECKING import pytest if TYPE_CHECKING: from collections.abc import Callable, Iterator from pytest_mock import MockerFixture # Coverage restarts in every patched subprocess and re-imports the plugin there; pytest's pythonpath reaches only # this process, so export it. os.environ["PYTHONPATH"] = os.pathsep.join( part for part in (str(Path(__file__).parent.parent / "tasks"), os.environ.get("PYTHONPATH")) if part ) def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: # StrictSoftFileLock publishes claims with hard links, so tests marked requires_hard_links cannot run where # os.link is missing (Termux/Android CPython ships without it). The no-hard-link degradation itself is covered by # the os.link tests in test_filelock.py, which carry no marker and run everywhere. if not hasattr(os, "link"): # pragma: no cover # the body runs only on Termux/Android, absent from the CI matrix skip = pytest.mark.skip(reason="StrictSoftFileLock requires os.link (hard links), absent on Termux/Android") for item in items: if item.get_closest_marker("requires_hard_links") is not None: item.add_marker(skip) @pytest.fixture def close_failure( mocker: MockerFixture, ) -> Iterator[tuple[Callable[[int], None], OSError, RuntimeError]]: locked_fd: int | None = None release_error = OSError("release failed") release_cause = RuntimeError("release cause") release_error.__cause__ = release_cause release_error.__suppress_context__ = True real_close = os.close def capture(fd: int) -> None: nonlocal locked_fd locked_fd = fd mocker.patch("filelock._api.os.close", side_effect=close) def close(fd: int) -> None: # filelock._api.os is the os module, so this patches os.close process-wide. Raise only for the descriptor # under test. An unrelated close inside a GC finalizer would escape as an unraisable exception. if fd == locked_fd: raise release_error real_close(fd) # pragma: no cover # only an unrelated close (e.g. a GC finalizer) reaches here yield capture, release_error, release_cause if locked_fd is not None: # pragma: no branch # every consumer calls capture, so this is always set real_close(locked_fd) filelock-3.32.2/tests/fork_helpers.py0000644000000000000000000000470613615410400014517 0ustar00from __future__ import annotations import os from typing import TYPE_CHECKING, Final, NoReturn, cast from capabilities import CAPABILITIES if TYPE_CHECKING: from collections.abc import Callable from typing import Protocol class _RegisterAtFork(Protocol): def __call__(self, *, after_in_child: Callable[[], None] | None = None) -> None: ... _FORK: Final[Callable[[], int] | None] = cast( "Callable[[], int] | None", getattr(os, "fork", None), # fork is absent from Windows at runtime and in its type interface ) _REGISTER_AT_FORK: Final[_RegisterAtFork | None] = cast( "_RegisterAtFork | None", getattr( # register_at_fork is absent from Windows at runtime and in its type interface os, "register_at_fork", None, ), ) def fork_process(child: Callable[[], NoReturn] | None = None) -> int: # pragma: win32 no cover if _FORK is None: # pragma: no cover - platform without os.fork msg = "os.fork is unavailable" raise RuntimeError(msg) child_pid = _FORK() if child_pid == 0 and child is not None: # pragma: win32 no cover child() # pragma: no cover - child coverage starts after fork returns to this frame return child_pid def exit_child(status: int) -> NoReturn: try: _flush_coverage() finally: # A coverage write failure must not return the fork child to pytest. os._exit(status) def _flush_coverage() -> None: # pragma: win32 no cover - a spawned child inherits no coverage to flush # An unmeasured run has nothing to write, and importing coverage there would stop the suite from running at all. if not CAPABILITIES["coverage"]: # pragma: lacks coverage return from coverage import Coverage # Reaching the other side needs coverage inactive, which is when nothing gets recorded anyway. if (coverage := Coverage.current()) is not None: # pragma: no branch coverage.save() def _restart_coverage_after_fork() -> None: # pragma: win32 no cover from coverage import Coverage, process_startup if (coverage := Coverage.current()) is not None: # pragma: win32 no cover coverage.stop() process_startup(force=True, slug="fork") # pragma: no cover - coverage is stopped until this call returns if _REGISTER_AT_FORK is not None and CAPABILITIES["coverage"]: # pragma: win32 no cover _REGISTER_AT_FORK(after_in_child=_restart_coverage_after_fork) __all__ = ["exit_child", "fork_process"] filelock-3.32.2/tests/process_helpers.py0000644000000000000000000000226413615410400015231 0ustar00from __future__ import annotations from contextlib import contextmanager from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from collections.abc import Generator from multiprocessing.process import BaseProcess #: Stays under the suite's per-test timeout so a wedged worker still surfaces as the hang it is. _REAP_DEADLINE: Final[int] = 5 @contextmanager def cleanup_processes(processes: list[BaseProcess]) -> Generator[None]: """Reap every process on the way out, whatever the block did.""" try: yield finally: for process in processes: # Terminating an unstarted process raises over the assertion that escaped before it started. if process.pid is not None: process.terminate() process.join(timeout=_REAP_DEADLINE) if process.is_alive(): # pragma: no cover # SIGTERM only loses this race on a saturated runner process.kill() process.join(timeout=_REAP_DEADLINE) # Left open, the finalizer runs during whichever test the collector lands in, as an unraisable exception. process.close() __all__ = ["cleanup_processes"] filelock-3.32.2/tests/read_write_helpers.py0000644000000000000000000000245713615410400015704 0ustar00from __future__ import annotations import multiprocessing from typing import TYPE_CHECKING, Literal from filelock import ReadWriteLock, Timeout if TYPE_CHECKING: from multiprocessing.sharedctypes import Synchronized def assert_read_write_lock_state(lock_file: str, mode: Literal["read", "write"], *, available: bool) -> None: context = multiprocessing.get_context("spawn") acquired = context.Value("b", False) probe = context.Process(target=_probe_read_write_lock, args=(lock_file, mode, acquired)) probe.start() try: probe.join(timeout=5) assert not probe.is_alive(), "read-write lock probe did not exit" assert (probe.exitcode, acquired.value) == (0, available) finally: if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails probe.terminate() probe.join(timeout=5) probe.close() def _probe_read_write_lock(lock_file: str, mode: Literal["read", "write"], acquired: Synchronized[bool]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) try: (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) except Timeout: return acquired.value = True lock.release() lock.close() __all__ = ["assert_read_write_lock_state"] filelock-3.32.2/tests/test_async_filelock.py0000644000000000000000000012165013615410400016056 0ustar00from __future__ import annotations import asyncio import gc import logging import os import sys import threading import traceback from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from errno import EINTR, EIO, ENOSYS from pathlib import Path, PurePath from typing import TYPE_CHECKING, Literal import pytest from filelock import ( AsyncFileLock, AsyncSoftFileLock, BaseAsyncFileLock, CloseErrorPolicy, ContextErrorPolicy, Timeout, ) from tests.capability_marks import NEEDS_FCNTL, NEEDS_PARENT_SYMLINK_COLLAPSE if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup, ExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / filename lock = lock_type(path_type(lock_path)) async with lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked assert caplog.messages == [ f"Attempting to acquire lock {id(lock)} on {lock_path}", f"Lock {id(lock)} acquired on {lock_path}", f"Attempting to release lock {id(lock)} on {lock_path}", f"Lock {id(lock)} released on {lock_path}", ] assert [r.levelno for r in caplog.records] == [logging.DEBUG, logging.DEBUG, logging.DEBUG, logging.DEBUG] assert [r.name for r in caplog.records] == ["filelock", "filelock", "filelock", "filelock"] assert logging.getLogger("filelock").level == logging.NOTSET @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("path_type", [str, PurePath, Path]) @pytest.mark.parametrize("filename", ["a", "new/b", "new2/new3/c"]) @pytest.mark.asyncio async def test_acquire( lock_type: type[BaseAsyncFileLock], path_type: type[str | Path], filename: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / filename lock = lock_type(path_type(lock_path)) async with await lock.acquire() as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked assert caplog.messages == [ f"Attempting to acquire lock {id(lock)} on {lock_path}", f"Lock {id(lock)} acquired on {lock_path}", f"Attempting to release lock {id(lock)} on {lock_path}", f"Lock {id(lock)} released on {lock_path}", ] assert [r.levelno for r in caplog.records] == [logging.DEBUG, logging.DEBUG, logging.DEBUG, logging.DEBUG] assert [r.name for r in caplog.records] == ["filelock", "filelock", "filelock", "filelock"] assert logging.getLogger("filelock").level == logging.NOTSET @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_blocking(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_3 = lock_type(str(lock_path), blocking=False) lock_4 = lock_type(str(lock_path), timeout=0) lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1) await lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_2.acquire(blocking=False) assert not lock_2.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_3.acquire() assert not lock_3.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_3: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_3.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_4.acquire() assert not lock_4.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_4: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_4.is_locked assert lock_1.is_locked # blocking precedence over timeout with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_5.acquire() assert not lock_5.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): async with lock_5: pass # pragma: no cover # __aenter__ raises Timeout, so the body never runs assert not lock_5.is_locked assert lock_1.is_locked await lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.parametrize("thread_local", [True, False]) @pytest.mark.asyncio async def test_non_executor(lock_type: type[BaseAsyncFileLock], thread_local: bool, tmp_path: Path) -> None: lock = lock_type(str(tmp_path / "a"), thread_local=thread_local, run_in_executor=False) async with lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked @pytest.mark.asyncio async def test_coroutine_function(tmp_path: Path) -> None: acquired = released = False class AioFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] nonlocal acquired acquired = True self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal released released = True self._context.lock_file_fd = None lock = AioFileLock(str(tmp_path / "a")) await lock.acquire() assert acquired assert not released await lock.release() assert acquired assert released @pytest.mark.asyncio async def test_coroutine_acquire_without_holding_polls_and_skips_on_acquired(tmp_path: Path) -> None: attempts: list[str] = [] class NeverHoldsLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] attempts.append(self.lock_file) # returns without setting the fd, so is_locked stays False async def _release(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = None # pragma: no cover # never held, so release never runs lock = NeverHoldsLock(str(tmp_path / "a"), timeout=0.1) with pytest.raises(Timeout): await lock.acquire() # The coroutine backend returned without acquiring, so the tracker skipped on_acquired and the poll loop retried. assert len(attempts) >= 2 assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_wait_message_logged( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" first_lock = lock_type(str(lock_path)) second_lock = lock_type(str(lock_path), timeout=0.2) # Hold the lock so second_lock has to wait await first_lock.acquire() with pytest.raises(Timeout): await second_lock.acquire() assert any("waiting" in msg for msg in caplog.messages) @pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) @pytest.mark.asyncio async def test_attempting_to_acquire_branch( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock = lock_type(str(tmp_path / "a")) await lock.acquire() assert any("Attempting to acquire lock" in m for m in caplog.messages) await lock.release() @pytest.mark.asyncio async def test_thread_local_run_in_executor(tmp_path: Path) -> None: # ruff:ignore[unused-async] # the asyncio marker requires a coroutine though the body awaits nothing with pytest.raises(ValueError, match="run_in_executor is not supported when thread_local is True"): AsyncSoftFileLock(str(tmp_path / "a"), thread_local=True, run_in_executor=True) @pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) @pytest.mark.asyncio async def test_attempting_to_acquire( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) await lock.acquire(timeout=0.1) assert any("Attempting to acquire lock" in m for m in caplog.messages) await lock.release() @pytest.mark.parametrize("lock_type", [AsyncSoftFileLock, AsyncFileLock]) @pytest.mark.asyncio async def test_attempting_to_release( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) await lock.acquire(timeout=0.1) await lock.acquire(timeout=0.1) # reentrant acquire await lock.release(force=True) assert any("Attempting to release lock" in m for m in caplog.messages) assert any("released" in m for m in caplog.messages) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_release_early_exit_when_unlocked(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) assert not lock.is_locked await lock.release() assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_release_nonzero_counter_exit( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock = lock_type(str(tmp_path / "a.lock"), run_in_executor=False) await lock.acquire() await lock.acquire() await lock.release() assert lock.lock_counter == 1 assert lock.is_locked assert not any("Attempting to release" in m for m in caplog.messages) await lock.release() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_triggers(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) await lock_1.acquire() with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): await lock_2.acquire(timeout=1, cancel_check=lambda: True) assert not lock_2.is_locked await lock_1.release() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_not_called_when_lock_available(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: lock = lock_type(str(tmp_path / "a")) called = False def should_not_be_called() -> bool: # pragma: no cover # a free lock never consults cancel_check nonlocal called called = True return True await lock.acquire(cancel_check=should_not_be_called) assert lock.is_locked assert not called await lock.release() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cancel_check_log_message( lock_type: type[BaseAsyncFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) await lock_1.acquire() with pytest.raises(Timeout): await lock_2.acquire(timeout=1, cancel_check=lambda: True) assert any("Cancellation requested" in msg for msg in caplog.messages) await lock_1.release() @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="async"), pytest.param(AsyncSoftFileLock, id="soft")], ) def test_sync_with_raises_not_implemented_error(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: # __exit__ must exist so Python can call it after __enter__ raises; without it AttributeError hides the real error with pytest.raises(NotImplementedError, match=r"async with"), lock_type(str(tmp_path / "test.lock")): pass # pragma: no cover # __enter__ raises NotImplementedError, so the body never runs @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="async"), pytest.param(AsyncSoftFileLock, id="soft")], ) def test_del_after_loop_close_does_not_raise(lock_type: type[BaseAsyncFileLock], tmp_path: Path) -> None: # __del__ must not call get_running_loop(); it raises RuntimeError when no loop is running def _run() -> None: lock = lock_type(str(tmp_path / "test.lock")) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(lock.acquire()) loop.run_until_complete(lock.release(force=True)) finally: loop.close() asyncio.set_event_loop(None) del lock gc.collect() with ThreadPoolExecutor(max_workers=1) as pool: pool.submit(_run).result(timeout=10) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_same_task_different_instances_raises(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path) with pytest.raises(RuntimeError, match="Deadlock"): await lock2.acquire() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_finite_timeout_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path, timeout=0.1) with pytest.raises(Timeout): await lock2.acquire() @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_non_blocking_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: lock2 = lock_type(lock_path, blocking=False) with pytest.raises(Timeout): await lock2.acquire() @pytest.mark.parametrize( "mode", [pytest.param("finite", id="finite"), pytest.param("nonblocking", id="nonblocking")], ) @pytest.mark.asyncio async def test_failed_acquire_keeps_holder_registered(tmp_path: Path, mode: Literal["finite", "nonblocking"]) -> None: lock_path = tmp_path / "test.lock" async with AsyncFileLock(lock_path, run_in_executor=False): with pytest.raises(Timeout): await _acquire_for_mode(AsyncFileLock(lock_path, run_in_executor=False), mode) with pytest.raises(RuntimeError, match="Deadlock"): await AsyncFileLock(lock_path, run_in_executor=False).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_different_paths_no_conflict(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock1 = lock_type(tmp_path / "a.lock") lock2 = lock_type(tmp_path / "b.lock") async with lock1, lock2: assert lock1.is_locked assert lock2.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_same_instance_reentrant_works(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock = lock_type(tmp_path / "test.lock") async with lock: async with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_singleton_avoids_deadlock(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, is_singleton=True) async with lock1: lock2 = lock_type(lock_path, is_singleton=True) assert lock1 is lock2 async with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_different_tasks_no_false_positive(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # The registry is per-thread, so a second acquire from a different task must not look like a reentrant deadlock. lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, timeout=0) await lock1.acquire() error: BaseException | None = None async def acquire_other() -> None: nonlocal error lock2 = lock_type(lock_path, timeout=0) try: await lock2.acquire() except BaseException as exc: error = exc await asyncio.create_task(acquire_other()) await lock1.release() assert not isinstance(error, RuntimeError), "Should not raise RuntimeError in a different task" @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_cross_task_blocking_acquire_queues(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # A blocking acquire from a different task must queue behind the holder, not raise. Polling yields the event # loop on every iteration, so the holder keeps running and releases; only a same-task reentry can deadlock. # The waiter starts on an event the holder sets once it holds: yielding with sleep(0) instead would let the # waiter run its check before the holder registers, so an empty registry would pass even with the bug. lock_path = tmp_path / "test.lock" events: list[str] = [] holding = asyncio.Event() async def holder() -> None: async with lock_type(lock_path): events.append("holder:acquired") holding.set() await asyncio.sleep(0.05) # the waiter polls while the lock is held events.append("holder:released") async def waiter() -> None: await holding.wait() async with lock_type(lock_path): events.append("waiter:acquired") await asyncio.gather(holder(), waiter()) # propagates a false-positive Deadlock assert events == ["holder:acquired", "holder:released", "waiter:acquired"] @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_release_from_different_task_clears_registry(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # The holder scope is pinned at commit time, so releasing from a different task still drops the registry entry. lock_path = tmp_path / "test.lock" lock = lock_type(lock_path) await lock.acquire() async def release() -> None: await lock.release() await asyncio.create_task(release()) async with lock_type(lock_path): # would raise Deadlock if the scoped entry had leaked pass @NEEDS_PARENT_SYMLINK_COLLAPSE @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio # pragma: win32 no cover async def test_symlink_same_canonical_path(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: # A symlinked parent directory resolves to the same canonical key; the final component stays literal. real_dir = tmp_path / "real" real_dir.mkdir() (tmp_path / "link").symlink_to(real_dir) lock1 = lock_type(str(real_dir / "test.lock")) async with lock1: lock2 = lock_type(str(tmp_path / "link" / "test.lock")) with pytest.raises(RuntimeError, match="Deadlock"): await lock2.acquire() @NEEDS_FCNTL @pytest.mark.parametrize( ("depth", "force"), [ pytest.param(1, False, id="direct"), pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced"), ], ) @pytest.mark.asyncio # pragma: needs fcntl async def test_release_drops_acquisition_key_after_parent_retarget(tmp_path: Path, depth: int, force: bool) -> None: lock_path, original_path, replacement_path = _symlinked_lock_paths(tmp_path) lock = AsyncFileLock(lock_path, run_in_executor=False) for _depth in range(depth): await lock.acquire() _retarget_parent(lock_path, replacement_path) if force: await lock.release(force=True) else: for _depth in range(depth): await lock.release() async with AsyncFileLock(original_path, run_in_executor=False) as successor: assert successor.is_locked @NEEDS_FCNTL @pytest.mark.asyncio async def test_release_keeps_retargeted_parent_holder_registered(tmp_path: Path) -> None: # pragma: needs fcntl lock_path, _original_path, replacement_path = _symlinked_lock_paths(tmp_path) original = AsyncFileLock(lock_path, run_in_executor=False) await original.acquire() _retarget_parent(lock_path, replacement_path) async with AsyncFileLock(replacement_path, run_in_executor=False): await original.release() with pytest.raises(RuntimeError, match="Deadlock"): await AsyncFileLock(replacement_path, run_in_executor=False).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_deadlock_registry_cleanup_on_release(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) await lock1.acquire() await lock1.release() lock2 = lock_type(lock_path) async with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [AsyncFileLock, AsyncSoftFileLock]) @pytest.mark.asyncio async def test_force_release_clears_registry(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) async with lock1: await lock1.acquire() assert lock1.lock_counter == 2 await lock1.release(force=True) lock2 = lock_type(lock_path) async with lock2: assert lock2.is_locked def _symlinked_lock_paths(tmp_path: Path) -> tuple[Path, Path, Path]: # pragma: needs fcntl original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) return link / "test.lock", original / "test.lock", replacement / "test.lock" def _retarget_parent(lock_path: Path, replacement_path: Path) -> None: # pragma: needs fcntl link = lock_path.parent link.unlink() link.symlink_to(replacement_path.parent, target_is_directory=True) async def _acquire_for_mode(lock: BaseAsyncFileLock, mode: Literal["finite", "nonblocking"]) -> None: if mode == "finite": await lock.acquire(timeout=0) else: await lock.acquire(blocking=False) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(str(tmp_path / "a")) await lock.acquire() mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): await lock.release() assert lock.is_locked assert lock.lock_counter == 1 await lock.release() assert not lock.is_locked @pytest.mark.asyncio async def test_async_zero_write_rolls_back_acquire(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = AsyncSoftFileLock(str(tmp_path / "a")) with pytest.raises(OSError, match="0 bytes"): await lock.acquire() assert not lock.is_locked @pytest.mark.parametrize( ("policy", "surfaces"), [pytest.param("default", True, id="default"), pytest.param("suppress", False, id="suppress")], ) @pytest.mark.asyncio async def test_soft_close_error_policy_cleans_marker( tmp_path: Path, mocker: MockerFixture, policy: CloseErrorPolicy, *, surfaces: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, close_error_policy=policy, run_in_executor=False) await lock.acquire() with _close_after_commit(mocker) as (close_error, attempts): if surfaces: with pytest.raises(OSError, match="close failed") as info: await lock.release() assert info.value is close_error else: await lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( ("depth", "force"), [pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced")], ) @pytest.mark.asyncio async def test_soft_close_error_suppression_releases_once( tmp_path: Path, mocker: MockerFixture, depth: int, *, force: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, close_error_policy="suppress", run_in_executor=False) for _acquisition in range(depth): await lock.acquire() with _close_after_commit(mocker) as (_, attempts): if force: await lock.release(force=True) else: for _release in range(depth): await lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_soft_context_surfaces_close_error_after_cleanup( tmp_path: Path, mocker: MockerFixture, *, use_proxy: bool, ) -> None: lock_path = tmp_path / "a" lock = AsyncSoftFileLock(lock_path, run_in_executor=False) with _close_after_commit(mocker) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info: async with await lock.acquire() if use_proxy else lock: pass assert (info.value, len(attempts), lock.is_locked, lock_path.exists()) == (close_error, 1, False, False) @pytest.mark.asyncio async def test_soft_second_release_does_not_close_reused_descriptor( tmp_path: Path, mocker: MockerFixture, ) -> None: lock = AsyncSoftFileLock(tmp_path / "a", run_in_executor=False) await lock.acquire() with _close_after_commit(mocker) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info: await lock.release() assert info.value is close_error reused_fd = os.open(tmp_path / "reused", os.O_CREAT | os.O_WRONLY) assert reused_fd == attempts[0] try: await lock.release() os.fstat(reused_fd) finally: os.close(reused_fd) @contextmanager def _close_after_commit(mocker: MockerFixture) -> Iterator[tuple[OSError, list[int]]]: real_close = os.close close_error = OSError(EINTR, "close failed") attempts: list[int] = [] def close(fd: int) -> None: real_close(fd) attempts.append(fd) raise close_error close_mock = mocker.patch("filelock._soft.os.close", side_effect=close) try: yield close_error, attempts finally: mocker.stop(close_mock) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure body_error = ValueError("body failed") body_cause = LookupError("body cause") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(ExceptionGroup) as info: async with await lock.acquire() if use_proxy else lock: raise body_error from body_cause assert ( info.value.exceptions, body_error.__context__, release_error.__context__, body_error.__cause__, release_error.__cause__, body_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((body_error, release_error), None, None, body_cause, release_cause, True, True) @pytest.mark.skipif( sys.version_info < (3, 11), reason="standard exception-group rendering requires Python 3.11" ) # pragma: >=3.11 cover @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_group_renders_independent_leaves( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure release_error.__cause__ = None release_error.__suppress_context__ = False body_error = ValueError("body failed") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(ExceptionGroup) as info: async with await lock.acquire() if use_proxy else lock: raise body_error group_rendering = "".join(traceback.format_exception(info.value)) release_rendering = "".join(traceback.format_exception(release_error)) assert ( group_rendering.count("ValueError: body failed"), release_rendering.count("ValueError: body failed"), release_rendering.count("RuntimeError: release cause"), release_rendering.count("OSError: release failed"), ) == (1, 0, 0, 1) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_chain_keeps_release_error_with_body_in_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="chain", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(OSError, match="release failed") as info: async with await lock.acquire() if use_proxy else lock: raise body_error assert (info.value, release_error.__context__) == (release_error, body_error) @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_body_only_failure_propagates_body( tmp_path: Path, policy: ContextErrorPolicy, *, use_proxy: bool ) -> None: lock = AsyncSoftFileLock(str(tmp_path / "a"), context_error_policy=policy) body = ValueError("body failed") with pytest.raises(ValueError, match="body failed") as info: async with await lock.acquire() if use_proxy else lock: raise body assert info.value is body @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) @pytest.mark.asyncio async def test_context_release_only_failure_propagates_release( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], policy: ContextErrorPolicy, *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy=policy, close_error_policy="raise", on_acquired=capture, ) with pytest.raises(OSError, match="release failed") as info: async with await lock.acquire() if use_proxy else lock: pass assert (info.value, release_error.__context__, release_error.__cause__) == (release_error, None, release_cause) @pytest.mark.asyncio async def test_context_group_base_exception_leaf_is_base_group( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, _, _ = close_failure lock = AsyncFileLock( str(tmp_path / "a"), thread_local=False, context_error_policy="group", close_error_policy="raise", on_acquired=capture, ) with pytest.raises(BaseExceptionGroup) as info: async with lock: raise KeyboardInterrupt assert not isinstance(info.value, ExceptionGroup) assert [type(leaf) for leaf in info.value.exceptions] == [KeyboardInterrupt, OSError] @pytest.mark.asyncio async def test_context_group_preserves_user_release_cancellation_group(tmp_path: Path) -> None: body_error = ValueError("body failed") backend_cancellation = asyncio.CancelledError("backend canceled") backend_error = OSError(EIO, "backend failed") release_group = BaseExceptionGroup( "lock release cancellation and backend release both failed", (backend_cancellation, backend_error), ) fail_release = True class GroupReleaseLock(BaseAsyncFileLock): def _acquire(self) -> None: self._context.lock_file_fd = 1 def _release(self) -> None: nonlocal fail_release if fail_release: fail_release = False raise release_group self._context.lock_file_fd = None lock = GroupReleaseLock(tmp_path / "a", run_in_executor=False, context_error_policy="group") with pytest.raises(BaseExceptionGroup) as info: async with lock: raise body_error assert info.value.exceptions == (body_error, release_group) assert info.value.exceptions[1] is release_group assert release_group.exceptions == (backend_cancellation, backend_error) await lock.release() def _fail_close_of(mocker: MockerFixture, lock: BaseAsyncFileLock, error: OSError) -> None: # pragma: needs fcntl # Fail os.close only for this lock's descriptor, so an unrelated lock's __del__ close during the test is untouched. fd = lock._context.lock_file_fd real_close = os.close def close(target: int) -> None: if target == fd: raise error real_close(target) # pragma: no cover # only an unrelated __del__ close during the mock window reaches here mocker.patch("filelock._api.os.close", side_effect=close) @NEEDS_FCNTL @pytest.mark.asyncio async def test_close_error_raise_propagates(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl lock = AsyncFileLock(str(tmp_path / "a"), close_error_policy="raise") await lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): await lock.release() assert not lock.is_locked @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_close_error_default_suppressed_on_unix(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(str(tmp_path / "a")) # default policy await lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) await lock.release() assert not lock.is_locked @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_fallback_to_soft_disabled_raises_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = AsyncFileLock(str(tmp_path / "a"), fallback_to_soft=False) with pytest.raises(OSError, match="no flock"): await lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "AsyncUnixFileLock" # not swapped to the soft class def test_preserve_lock_file_async_soft_rejects(tmp_path: Path) -> None: with pytest.raises(ValueError, match="preserve_lock_file"): AsyncSoftFileLock(str(tmp_path / "a"), preserve_lock_file=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_preserve_lock_file_async_release_keeps_pathname(tmp_path: Path) -> None: # pragma: needs fcntl path = tmp_path / "a" lock = AsyncFileLock(str(path), preserve_lock_file=True) await lock.acquire() await lock.release() assert path.exists() # the native pathname survives an async release assert type(lock).__name__ == "AsyncUnixFileLock" def _failing_on_acquired(_fd: int) -> None: # pragma: needs fcntl msg = "hook failed" raise RuntimeError(msg) def test_on_acquired_async_soft_rejects(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired"): AsyncSoftFileLock(str(tmp_path / "a"), on_acquired=lambda _fd: None) @NEEDS_FCNTL @pytest.mark.asyncio async def test_on_acquired_runs_in_backend_executor(tmp_path: Path) -> None: # pragma: needs fcntl hook_thread = -1 fd_while_held = -1 def hook(fd: int) -> None: nonlocal hook_thread, fd_while_held hook_thread = threading.get_ident() if lock.is_locked: # pragma: no branch # on_acquired only ever runs while the lock is held fd_while_held = fd lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, on_acquired=hook) await lock.acquire() try: assert fd_while_held >= 0 # the hook ran while the lock was held, with a real descriptor assert hook_thread != threading.get_ident() # in the backend executor, not the event-loop thread finally: await lock.release() @NEEDS_FCNTL @pytest.mark.asyncio async def test_on_acquired_async_failure_releases(tmp_path: Path) -> None: # pragma: needs fcntl lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, on_acquired=_failing_on_acquired) with pytest.raises(RuntimeError, match="hook failed"): await lock.acquire() assert not lock.is_locked assert lock.lock_counter == 0 @pytest.mark.asyncio async def test_on_acquired_rollback_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, release_error, release_cause = close_failure callback_error = RuntimeError("hook failed") callback_cause = LookupError("hook cause") def fail(fd: int) -> None: capture(fd) raise callback_error from callback_cause lock = AsyncFileLock(str(tmp_path / "a"), thread_local=False, close_error_policy="raise", on_acquired=fail) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert ( info.value.exceptions, callback_error.__context__, release_error.__context__, callback_error.__cause__, release_error.__cause__, callback_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((callback_error, release_error), None, None, callback_cause, release_cause, True, True) def test_async_del_without_any_loop_returns(tmp_path: Path, mocker: MockerFixture) -> None: # GC can finalize a lock with no loop running and none remembered, where releasing is impossible. Raise the lookup # rather than rely on no loop running here, which the surrounding tests decide. get_running_loop = mocker.patch("filelock.asyncio.asyncio.get_running_loop", side_effect=RuntimeError) lock = AsyncSoftFileLock(str(tmp_path / "a")) release = mocker.patch.object(lock, "release", autospec=True) # Any lock another test dropped reaches the same patched lookup, so only calls from here on count. get_running_loop.reset_mock() lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing # An unlocked finalizer asserts nothing on its own; the lookup and the absent release pin the path it took. assert (get_running_loop.called, release.called, lock.is_locked) == (True, False, False) filelock-3.32.2/tests/test_async_filelock_acquire_cancellation.py0000644000000000000000000004555313615410400022312 0ustar00from __future__ import annotations import asyncio import logging import sys import threading from concurrent.futures import ThreadPoolExecutor from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import ( AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy, ) from tests.async_filelock_cancellation_helpers import assert_cancellation_message, assert_file_lock_state from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup, ExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( None: backend_cancellation = asyncio.CancelledError("backend canceled") released = False class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal released released = True self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False) with pytest.raises(asyncio.CancelledError, match="backend canceled"): await lock.acquire() assert (released, lock.is_locked, lock.lock_counter) == (True, False, 0) @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_backend_cancellation_rollback_failure_follows_policy(tmp_path: Path, policy: ContextErrorPolicy) -> None: backend_cancellation = asyncio.CancelledError("backend canceled") rollback_error = OSError(EIO, "rollback failed") prior_leaf = LookupError("prior rollback failure") prior_cause = RuntimeError("prior rollback cause") prior_rollback_error = ExceptionGroup("prior rollback failures", (prior_leaf,)) prior_rollback_error.__cause__ = prior_cause prior_cause.__context__ = prior_rollback_error fail_rollback = True class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_rollback: try: raise prior_rollback_error except ExceptionGroup: raise rollback_error # ruff:ignore[raise-without-from-inside-except] # exercise implicit backend context preservation self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False, context_error_policy=policy) if policy == "chain": with pytest.raises(OSError, match="rollback failed") as info: await lock.acquire() assert (info.value, rollback_error.__context__, backend_cancellation.__context__) == ( rollback_error, backend_cancellation, prior_rollback_error, ) else: with pytest.raises(BaseExceptionGroup) as info: await lock.acquire() assert (info.value.exceptions, rollback_error.__context__) == ( (backend_cancellation, rollback_error), prior_rollback_error, ) assert (lock.is_locked, lock.lock_counter) == (True, 1) fail_rollback = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_caller_and_backend_cancellation_both_surface(tmp_path: Path) -> None: acquire_started = asyncio.Event() finish_acquire = asyncio.Event() backend_cancellation = asyncio.CancelledError("backend canceled") class CancellingFileLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 acquire_started.set() await finish_acquire.wait() raise backend_cancellation async def _release(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = None lock = CancellingFileLock(tmp_path / "a", run_in_executor=False, context_error_policy="group") task = asyncio.create_task(lock.acquire()) await acquire_started.wait() task.cancel("caller canceled") finish_acquire.set() with pytest.raises(BaseExceptionGroup) as info: await task caller_cancellation, grouped_backend_cancellation = info.value.exceptions assert isinstance(caller_cancellation, asyncio.CancelledError) assert (caller_cancellation.args, grouped_backend_cancellation) == (("caller canceled",), backend_cancellation) assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_acquire_cancellation_before_executor_start_rolls_back(tmp_path: Path) -> None: executor_started = threading.Event() release_executor = threading.Event() acquired = threading.Event() with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", executor=executor, on_acquired=lambda _fd: acquired.set()) task = asyncio.create_task(lock.acquire()) await asyncio.sleep(0) assert (lock.lock_counter, acquired.is_set()) == (1, False) task.cancel() release_executor.set() with pytest.raises(asyncio.CancelledError): await task blocker.result(timeout=5) assert acquired.is_set() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_acquire_cancellation_does_not_release_later_claim(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_acquire = asyncio.create_task(lock.acquire()) await hook_started.wait() second_acquire = asyncio.create_task(lock.acquire()) first_acquire.cancel("cancel first acquire") finish_hook.set() with pytest.raises(asyncio.CancelledError) as info: await first_acquire assert_cancellation_message(info.value, "cancel first acquire") await second_acquire assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancelled_queued_acquire_does_not_claim_transition(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_acquire = asyncio.create_task(lock.acquire()) await hook_started.wait() queued_acquire = asyncio.create_task(lock.acquire()) await asyncio.sleep(0) queued_acquire.cancel("cancel queued acquire") with pytest.raises(asyncio.CancelledError) as info: await queued_acquire assert_cancellation_message(info.value, "cancel queued acquire") assert lock.lock_counter == 1 finish_hook.set() await first_acquire await lock.acquire() assert lock.lock_counter == 2 await lock.release() await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl async def test_acquire_repeated_cancellation_waits_for_rollback(tmp_path: Path, mocker: MockerFixture) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) def block_rollback(_fd: int, _operation: int) -> None: loop.call_soon_threadsafe(rollback_started.set) assert finish_rollback.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch("filelock._unix.fcntl.flock", side_effect=block_rollback) task.cancel("first cancellation") finish_hook.set() await rollback_started.wait() task.cancel("second cancellation") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "first cancellation") assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_error_after_rollback( # pragma: needs fcntl tmp_path: Path, policy: ContextErrorPolicy ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() callback_error = ValueError("hook failed") prior_context = LookupError("prior callback failure") callback_error.__context__ = prior_context def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_error lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(ValueError, match="hook failed") as info: await task cancellation = info.value.__context__ assert info.value is callback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_callback_error = info.value.exceptions assert grouped_callback_error is callback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, cancellation.__context__, callback_error.__cause__, callback_error.__context__) == ( ("cancel acquire",), prior_context if policy == "chain" else None, None, cancellation if policy == "chain" else prior_context, ) assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize( ("context_message", "preserved"), [pytest.param("unrelated", True, id="distinct"), pytest.param("attempt", False, id="equivalent")], ) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_group_reconciles_attempt_context( # pragma: needs fcntl tmp_path: Path, context_message: str, *, preserved: bool ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() nested_leaf = LookupError("nested") callback_group = ExceptionGroup("attempt", (nested_leaf,)) prior_context = ExceptionGroup(context_message, (nested_leaf,)) callback_group.__context__ = prior_context def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_group lock = AsyncFileLock(tmp_path / "a", context_error_policy="group", on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() task.cancel("cancel acquire") finish_hook.set() with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_callback_error = info.value.exceptions assert isinstance(cancellation, asyncio.CancelledError) assert isinstance(grouped_callback_error, ExceptionGroup) assert grouped_callback_error is callback_group assert ( type(grouped_callback_error), grouped_callback_error.message, grouped_callback_error.exceptions, grouped_callback_error.__context__, callback_group.__context__, ) == ( ExceptionGroup, "attempt", (nested_leaf,), prior_context if preserved else None, prior_context if preserved else None, ) assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_rollback_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, policy: ContextErrorPolicy, ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) rollback_error = OSError(EIO, "rollback failed") lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=block_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch("filelock._unix.fcntl.flock", side_effect=[rollback_error, None]) task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(OSError, match="rollback failed") as info: await task cancellation = info.value.__context__ assert info.value is rollback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_rollback_error = info.value.exceptions assert grouped_rollback_error is rollback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, rollback_error.__cause__, rollback_error.__context__) == ( ("cancel acquire",), None, cancellation if policy == "chain" else None, ) assert (cancellation.__traceback__ is not None, rollback_error.__traceback__ is not None) == (True, True) assert not any(record.name == "asyncio" and record.levelno >= logging.ERROR for record in caplog.records) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_and_rollback_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() callback_error = ValueError("hook failed") def fail_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) raise callback_error first_rollback_error = OSError(EIO, "hook rollback failed") second_rollback_error = OSError(EIO, "cancellation rollback failed") lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy, on_acquired=fail_hook) task = asyncio.create_task(lock.acquire()) await hook_started.wait() mocker.patch( "filelock._unix.fcntl.flock", side_effect=[first_rollback_error, second_rollback_error, None], ) task.cancel("cancel acquire") finish_hook.set() if policy == "chain": with pytest.raises(OSError, match="cancellation rollback failed") as info: await task attempt_error = info.value.__context__ cancellation = attempt_error.__context__ if attempt_error is not None else None assert info.value is second_rollback_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, attempt_error, grouped_rollback_error = info.value.exceptions assert grouped_rollback_error is second_rollback_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(attempt_error, BaseExceptionGroup) assert (attempt_error.exceptions[0], attempt_error.exceptions[1]) == (callback_error, first_rollback_error) assert isinstance(cancellation, asyncio.CancelledError) assert ( cancellation.args, attempt_error.__cause__, attempt_error.__context__, second_rollback_error.__cause__, second_rollback_error.__context__, ) == ( ("cancel acquire",), None, cancellation if policy == "chain" else None, None, attempt_error if policy == "chain" else None, ) assert (callback_error.__context__, first_rollback_error.__context__) == (None, None) assert ( cancellation.__traceback__ is not None, callback_error.__traceback__ is not None, first_rollback_error.__traceback__ is not None, attempt_error.__traceback__ is not None, second_rollback_error.__traceback__ is not None, ) == (True, True, True, True, True) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @pytest.mark.asyncio async def test_non_executor_immediate_acquire_precedes_scheduled_callback(tmp_path: Path) -> None: callbacks: list[str] = [] asyncio.get_running_loop().call_soon(callbacks.append, "callback") lock = AsyncFileLock(tmp_path / "a", run_in_executor=False) await lock.acquire() assert callbacks == [] await lock.release() await asyncio.sleep(0) assert callbacks == ["callback"] def _block_executor(executor_started: threading.Event, release_executor: threading.Event) -> None: executor_started.set() assert release_executor.wait(timeout=5) filelock-3.32.2/tests/test_async_filelock_release_cancellation.py0000644000000000000000000002043713615410400022273 0ustar00from __future__ import annotations import asyncio import logging import sys import threading from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy from tests.async_filelock_cancellation_helpers import ( assert_cancellation_message, assert_file_lock_state, get_fcntl, start_file_lock_holder, ) from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup, ExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( None: lock = AsyncFileLock(str(tmp_path / "a")) await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() def block_unlock(_fd: int, _operation: int) -> None: loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) mocker.patch("filelock._unix.fcntl.flock", side_effect=block_unlock) task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(asyncio.CancelledError): await task assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_proceeds_after_queued_release_is_canceled(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(tmp_path / "a") await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() fcntl = get_fcntl() real_flock = fcntl.flock blocked = False def block_first_unlock(fd: int, operation: int) -> None: nonlocal blocked if operation & fcntl.LOCK_UN and not blocked: blocked = True loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) real_flock(fd, operation) mocker.patch("filelock._unix.fcntl.flock", side_effect=block_first_unlock) first_release = asyncio.create_task(lock.release()) await release_started.wait() second_release = asyncio.create_task(lock.release()) await asyncio.sleep(0) second_release.cancel("abandon queued release") try: with pytest.raises(asyncio.CancelledError) as info: await second_release assert_cancellation_message(info.value, "abandon queued release") acquire = asyncio.create_task(lock.acquire()) finally: finish_release.set() await first_release await acquire assert (lock.is_locked, lock.lock_counter) == (True, 1) await lock.release() assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.asyncio async def test_release_waits_for_provisional_acquire(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) acquire_task = asyncio.create_task(lock.acquire()) await hook_started.wait() release_started = asyncio.Event() async def release() -> None: release_started.set() await lock.release() release_task = asyncio.create_task(release()) await release_started.wait() finish_hook.set() await acquire_task await release_task assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @pytest.mark.asyncio async def test_release_returns_while_acquire_waits_for_external_holder(tmp_path: Path) -> None: holder, holder_started, finish_holder = start_file_lock_holder(str(tmp_path / "a")) assert await asyncio.to_thread(holder_started.wait, 5) first_polled = asyncio.Event() def observe_first_poll() -> bool: first_polled.set() return False lock = AsyncFileLock(tmp_path / "a") acquire_task = asyncio.create_task(lock.acquire(cancel_check=observe_first_poll, poll_interval=0.001)) try: await first_polled.wait() await lock.release() assert (acquire_task.done(), lock.is_locked) == (False, False) finally: finish_holder.set() await asyncio.to_thread(holder.join, 5) assert not holder.is_alive() await acquire_task await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, policy: ContextErrorPolicy ) -> None: lock = AsyncFileLock(tmp_path / "a", context_error_policy=policy) await lock.acquire() release_started = asyncio.Event() finish_release = threading.Event() loop = asyncio.get_running_loop() release_error = OSError(EIO, "release failed") release_count = 0 def fail_first_unlock(_fd: int, _operation: int) -> None: nonlocal release_count release_count += 1 if release_count == 1: loop.call_soon_threadsafe(release_started.set) assert finish_release.wait(timeout=5) raise release_error mocker.patch("filelock._unix.fcntl.flock", side_effect=fail_first_unlock) task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() if policy == "chain": with pytest.raises(OSError, match="release failed") as info: await task cancellation = info.value.__context__ assert info.value is release_error else: with pytest.raises(BaseExceptionGroup) as info: await task cancellation, grouped_release_error = info.value.exceptions assert grouped_release_error is release_error assert (info.value.__cause__, info.value.__suppress_context__) == (None, True) assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, release_error.__cause__, release_error.__context__) == ( ("cancel release",), None, cancellation if policy == "chain" else None, ) assert (cancellation.__traceback__ is not None, release_error.__traceback__ is not None) == (True, True) assert not any(record.name == "asyncio" and record.levelno >= logging.ERROR for record in caplog.records) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) await lock.release() @pytest.mark.asyncio async def test_context_chain_does_not_duplicate_body_already_in_release_group(tmp_path: Path) -> None: body_error = ValueError("body failed") release_group = ExceptionGroup("release failed", (body_error,)) fail_release = True class GroupReleaseLock(BaseAsyncFileLock): def _acquire(self) -> None: self._context.lock_file_fd = 1 def _release(self) -> None: nonlocal fail_release if fail_release: fail_release = False raise release_group self._context.lock_file_fd = None lock = GroupReleaseLock(tmp_path / "a", run_in_executor=False, context_error_policy="chain") with pytest.raises(ExceptionGroup) as info: async with lock: raise body_error assert (info.value, release_group.__context__, release_group.exceptions) == ( release_group, body_error, (body_error,), ) await lock.release() filelock-3.32.2/tests/test_async_filelock_rollback_agnostic.py0000644000000000000000000002253213615410400021615 0ustar00from __future__ import annotations import asyncio import sys from errno import EIO from typing import TYPE_CHECKING import pytest from filelock import AsyncFileLock, BaseAsyncFileLock from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup, ExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( None: release_started = asyncio.Event() finish_release = asyncio.Event() releases = 0 class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] nonlocal releases releases += 1 release_started.set() await finish_release.wait() self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) await lock.acquire() first = asyncio.create_task(lock.release()) await release_started.wait() second = asyncio.create_task(lock.release()) await asyncio.sleep(0) # let the queued release block on the transition gate before the holder unlocks finish_release.set() await first await second assert releases == 1 # the queued release found the lock already gone and did no backend work assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error(tmp_path: Path) -> None: backend_error = OSError(EIO, "backend release failed") release_started = asyncio.Event() finish_release = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: release_started.set() await finish_release.wait() raise backend_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) await lock.acquire() task = asyncio.create_task(lock.release()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(OSError, match="backend release failed") as info: await task assert info.value is backend_error assert isinstance(info.value.__context__, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # backend failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_rollback_failure_surfaces_backend_error(tmp_path: Path) -> None: rollback_error = OSError(EIO, "rollback release failed") acquire_started = asyncio.Event() finish_acquire = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] acquire_started.set() await finish_acquire.wait() self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False) task = asyncio.create_task(lock.acquire()) await acquire_started.wait() task.cancel("cancel acquire") finish_acquire.set() # let the backend finish acquiring, so cancellation must roll the acquired lock back with pytest.raises(OSError, match="rollback release failed") as info: await task assert info.value is rollback_error assert isinstance(info.value.__context__, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_registration_failure_rolls_back_and_surfaces_cleanup(tmp_path: Path) -> None: registration_error = RuntimeError("registration failed") rollback_error = OSError(EIO, "rollback release failed") fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None def _register_context_descriptor(self) -> None: # ruff:ignore[no-self-use] # overrides a base instance method raise registration_error lock = Lock(tmp_path / "a", run_in_executor=False) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert info.value.message == "descriptor registration cleanup failed" assert info.value.exceptions == (registration_error, rollback_error) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.parametrize( "rollback_fails", [pytest.param(True, id="rollback-fails"), pytest.param(False, id="rollback-succeeds")], ) @pytest.mark.asyncio async def test_acquire_failure_with_registration_failure_rolls_back(tmp_path: Path, *, rollback_fails: bool) -> None: acquisition_error = RuntimeError("acquire failed") registration_error = LookupError("registration failed") rollback_error = OSError(EIO, "rollback release failed") fail_release = rollback_fails class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 raise acquisition_error async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: raise rollback_error self._context.lock_file_fd = None def _register_context_descriptor(self) -> None: # ruff:ignore[no-self-use] # overrides a base instance method raise registration_error lock = Lock(tmp_path / "a", run_in_executor=False) with pytest.raises(ExceptionGroup) as info: await lock.acquire() assert info.value.message == "lock acquisition cleanup failed" if rollback_fails: assert info.value.exceptions == (acquisition_error, registration_error, rollback_error) assert (lock.is_locked, lock.lock_counter) == (True, 1) # rollback failed before unlocking, so state is kept fail_release = False await lock.release() else: assert info.value.exceptions == (acquisition_error, registration_error) assert (lock.is_locked, lock.lock_counter) == (False, 0) @pytest.mark.asyncio async def test_context_group_reconciles_release_cancellation_with_backend_error(tmp_path: Path) -> None: body_error = ValueError("body failed") backend_error = OSError(EIO, "backend release failed") release_started = asyncio.Event() finish_release = asyncio.Event() fail_release = True class Lock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] self._context.lock_file_fd = 1 async def _release(self) -> None: # ty: ignore[invalid-method-override] if fail_release: release_started.set() await finish_release.wait() raise backend_error self._context.lock_file_fd = None lock = Lock(tmp_path / "a", run_in_executor=False, context_error_policy="group") async def run_context() -> None: async with lock: raise body_error task = asyncio.create_task(run_context()) await release_started.wait() task.cancel("cancel release") finish_release.set() with pytest.raises(BaseExceptionGroup) as info: await task grouped_body, grouped_cancellation, grouped_backend = info.value.exceptions assert info.value.message == "context body, release cancellation, and backend release failed" assert (grouped_body, grouped_backend) == (body_error, backend_error) assert isinstance(grouped_cancellation, asyncio.CancelledError) assert (lock.is_locked, lock.lock_counter) == (True, 1) # backend failed before unlocking, so state is kept fail_release = False await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) def test_sync_context_exit_is_unsupported(tmp_path: Path) -> None: # __enter__ already raises, so the with-statement never reaches __exit__; call the protocol method directly. lock = AsyncFileLock(str(tmp_path / "a")) with pytest.raises(NotImplementedError, match=r"async with"): lock.__exit__(None, None, None) filelock-3.32.2/tests/test_async_filelock_runner_shutdown.py0000644000000000000000000001520613615410400021401 0ustar00from __future__ import annotations import asyncio import sys import threading from errno import EIO from queue import Queue from typing import TYPE_CHECKING, TypeVar import pytest from filelock import AsyncAcquireReturnProxy, AsyncFileLock, ContextErrorPolicy from tests.async_filelock_cancellation_helpers import assert_file_lock_state, get_fcntl from tests.capability_marks import NEEDS_FCNTL, XFAIL_WITHOUT_COROUTINE_CANCELLATION if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( None: self._cancellation_seen = cancellation_seen super().__init__(coroutine, loop=loop) def cancel(self, msg: object | None = None) -> bool: # Task.cancel accepts arbitrary payloads; object is the narrowest accurate type for its public contract. self._cancellation_seen.set() return super().cancel(msg) @NEEDS_FCNTL def test_runner_shutdown_waits_for_executor_acquire_rollback(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = threading.Event() finish_hook = threading.Event() cancellation_seen = threading.Event() def block_hook(_fd: int) -> None: hook_started.set() assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) tasks: Queue[asyncio.Task[AsyncAcquireReturnProxy]] = Queue() runner = threading.Thread( target=_run_unawaited_acquire, args=(lock, hook_started, cancellation_seen, tasks), ) runner.start() try: tasks.get(timeout=5) assert hook_started.wait(timeout=5) assert cancellation_seen.wait(timeout=5), "asyncio runner did not cancel the pending lock task" finally: finish_hook.set() runner.join(timeout=5) assert (runner.is_alive(), lock.is_locked, lock.lock_counter) == (False, False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_runner_shutdown_preserves_body_cancellation_and_release_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: body_error = ValueError("body failed") prior_error = LookupError("prior release failure") release_error = OSError(EIO, "release failed") release_error.__context__ = prior_error release_started = threading.Event() finish_release = threading.Event() failed = False fcntl = get_fcntl() real_flock = fcntl.flock def fail_first_unlock(fd: int, operation: int) -> None: nonlocal failed if operation & fcntl.LOCK_UN and not failed: failed = True release_started.set() assert finish_release.wait(timeout=5) raise release_error real_flock(fd, operation) mocker.patch("filelock._unix.fcntl.flock", side_effect=fail_first_unlock) lock = AsyncFileLock(tmp_path / "a", thread_local=False, context_error_policy=policy) runner, task, cancellation_seen = _start_unawaited_context_failure( lock, body_error, release_started, ) try: assert release_started.wait(timeout=5) assert cancellation_seen.wait(timeout=5), "asyncio runner did not cancel the pending lock task" finally: finish_release.set() runner.join(timeout=5) assert (runner.is_alive(), task.done()) == (False, True) error = task.exception() if policy == "chain": assert error is release_error cancellation = release_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.__context__, prior_error.__context__) == (prior_error, body_error) else: assert isinstance(error, BaseExceptionGroup) cancellation = error.exceptions[1] assert isinstance(cancellation, asyncio.CancelledError) assert error.exceptions == (body_error, cancellation, release_error) assert (cancellation.__context__, release_error.__context__, prior_error.__context__) == ( None, prior_error, None, ) assert (lock.is_locked, lock.lock_counter) == (True, 1) assert_file_lock_state(str(tmp_path / "a"), available=False) asyncio.run(lock.release()) assert_file_lock_state(str(tmp_path / "a"), available=True) def _run_unawaited_acquire( # pragma: needs fcntl lock: AsyncFileLock, hook_started: threading.Event, cancellation_seen: threading.Event, tasks: Queue[asyncio.Task[AsyncAcquireReturnProxy]], ) -> None: async def start_acquire() -> None: tasks.put( _CancellationObservedTask( lock.acquire(), cancellation_seen, loop=asyncio.get_running_loop(), ) ) assert await asyncio.to_thread(hook_started.wait, 5) asyncio.run(start_acquire()) def _start_unawaited_context_failure( # pragma: needs fcntl lock: AsyncFileLock, body_error: BaseException, release_started: threading.Event, ) -> tuple[threading.Thread, asyncio.Task[None], threading.Event]: tasks: Queue[asyncio.Task[None]] = Queue() cancellation_seen = threading.Event() runner = threading.Thread( target=_run_unawaited_context_failure, args=(lock, body_error, release_started, cancellation_seen, tasks), ) runner.start() task = tasks.get(timeout=5) return runner, task, cancellation_seen def _run_unawaited_context_failure( # pragma: needs fcntl lock: AsyncFileLock, body_error: BaseException, release_started: threading.Event, cancellation_seen: threading.Event, tasks: Queue[asyncio.Task[None]], ) -> None: async def fail_in_context() -> None: async with lock: raise body_error async def start_context() -> None: tasks.put( _CancellationObservedTask( fail_in_context(), cancellation_seen, loop=asyncio.get_running_loop(), ) ) assert await asyncio.to_thread(release_started.wait, 5) asyncio.run(start_context()) filelock-3.32.2/tests/test_async_filelock_transition_admission.py0000644000000000000000000001033013615410400022366 0ustar00from __future__ import annotations import asyncio import sys import threading from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncFileLock, Timeout from tests.async_filelock_cancellation_helpers import ( assert_cancellation_message, assert_file_lock_state, start_file_lock_holder, ) from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from pathlib import Path from typing import Literal _UNIX_FLOCK_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform == "win32", reason="native flock semantics are Unix-only" ) @pytest.mark.parametrize( "admission", [ pytest.param("nonblocking", id="nonblocking"), pytest.param("deadline", id="finite-deadline"), pytest.param("cancel-check", id="cancel-check"), ], ) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_honors_own_admission_policy( tmp_path: Path, admission: Literal["nonblocking", "deadline", "cancel-check"] ) -> None: first_polled = asyncio.Event() def observe_first_poll() -> bool: first_polled.set() return False holder, holder_started, finish_holder = start_file_lock_holder(str(tmp_path / "a")) assert await asyncio.to_thread(holder_started.wait, 5) lock = AsyncFileLock(tmp_path / "a") first_task = asyncio.create_task(lock.acquire(cancel_check=observe_first_poll, poll_interval=0.001)) try: await first_polled.wait() if admission == "nonblocking": with pytest.raises(Timeout): await lock.acquire(blocking=False) elif admission == "deadline": with pytest.raises(Timeout): await lock.acquire(timeout=0.01) else: cancel_second = asyncio.Event() second_started = asyncio.Event() async def acquire_second() -> None: second_started.set() await lock.acquire(cancel_check=cancel_second.is_set, poll_interval=0.001) second_task = asyncio.create_task(acquire_second()) await second_started.wait() cancel_second.set() with pytest.raises(Timeout): await second_task assert not first_task.done() finally: finish_holder.set() await asyncio.to_thread(holder.join, 5) assert not holder.is_alive() await first_task await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) @_UNIX_FLOCK_ONLY @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_proceeds_after_prior_waiter_cancels(tmp_path: Path) -> None: # pragma: win32 no cover hook_started = asyncio.Event() finish_hook = threading.Event() loop = asyncio.get_running_loop() def block_hook(_fd: int) -> None: # pragma: win32 no cover loop.call_soon_threadsafe(hook_started.set) assert finish_hook.wait(timeout=5) lock = AsyncFileLock(tmp_path / "a", on_acquired=block_hook) first_task = asyncio.create_task(lock.acquire()) await hook_started.wait() second_task, second_started = _start_signaled_acquire(lock) await second_started.wait() third_task, third_started = _start_signaled_acquire(lock) await third_started.wait() second_task.cancel("abandon queued acquire") try: # pragma: win32 no cover with pytest.raises(asyncio.CancelledError) as info: # pragma: win32 no cover await second_task assert_cancellation_message(info.value, "abandon queued acquire") finally: finish_hook.set() await first_task await third_task assert (lock.is_locked, lock.lock_counter) == (True, 2) await lock.release() await lock.release() assert (lock.is_locked, lock.lock_counter) == (False, 0) assert_file_lock_state(str(tmp_path / "a"), available=True) def _start_signaled_acquire(lock: AsyncFileLock) -> tuple[asyncio.Task[None], asyncio.Event]: # pragma: win32 no cover started = asyncio.Event() async def acquire() -> None: # pragma: win32 no cover started.set() await lock.acquire() return asyncio.create_task(acquire()), started filelock-3.32.2/tests/test_async_read_write.py0000644000000000000000000002442113615410400016411 0ustar00from __future__ import annotations import gc from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Literal import pytest from tests.capability_marks import NEEDS_COLLECTED_FINALIZATION pytest.importorskip("sqlite3") import sqlite3 from filelock import AsyncReadWriteLock, ReadWriteLock, Timeout if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path @pytest.fixture(autouse=True) def _clear_singleton_cache() -> Generator[None]: ReadWriteLock._instances.clear() yield for ref in list(ReadWriteLock._instances.valuerefs()): if (lock := ref()) is not None: # pragma: no cover # cache is normally emptied before teardown lock.close() ReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_release(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) proxy = await (lock.acquire_read() if mode == "read" else lock.acquire_write()) async with proxy as held: assert held is lock with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_lock_context_manager(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) ctx = lock.read_lock() if mode == "read" else lock.write_lock() async with ctx: assert_mode_held(lock_file, mode) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_reentrant(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write await acquire() await acquire() await lock.release() assert_mode_held(lock_file, mode) await lock.release() with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize( ("held", "requested", "match"), [ pytest.param("read", "write", r"already holding a read lock.*upgrade not allowed", id="upgrade"), pytest.param("write", "read", r"already holding a write lock.*downgrade not allowed", id="downgrade"), ], ) @pytest.mark.asyncio async def test_mode_change_prohibited( lock_file: str, held: Literal["read", "write"], requested: Literal["read", "write"], match: str, ) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await (lock.acquire_read() if held == "read" else lock.acquire_write()) with pytest.raises(RuntimeError, match=match): await (lock.acquire_read() if requested == "read" else lock.acquire_write()) await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_non_blocking_conflict(lock_file: str, mode: Literal["read", "write"]) -> None: holder = ReadWriteLock(lock_file, is_singleton=False) (holder.acquire_write if mode == "read" else holder.acquire_read)() try: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(Timeout): await (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) await lock.close() finally: holder.release() @pytest.mark.asyncio async def test_timeout_expires(lock_file: str) -> None: holder = ReadWriteLock(lock_file, is_singleton=False) holder.acquire_write() try: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(Timeout): await lock.acquire_read(timeout=0.2) await lock.close() finally: holder.release() @pytest.mark.asyncio async def test_release_unheld_raises(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_release_force(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() await lock.acquire_write() await lock.release(force=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_release_force_unheld_is_noop(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.release(force=True) await lock.close() @pytest.mark.parametrize( ("held", "supplied_executor"), [ pytest.param(False, False, id="idle-owned-executor"), pytest.param(True, False, id="held-owned-executor"), pytest.param(False, True, id="idle-supplied-executor"), pytest.param(True, True, id="held-supplied-executor"), ], ) @pytest.mark.asyncio async def test_close_rejects_later_acquisition(lock_file: str, held: bool, supplied_executor: bool) -> None: executor = ThreadPoolExecutor(max_workers=1) if supplied_executor else None lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) if held: await lock.acquire_write() await lock.close() with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): await lock.acquire_read() if executor is not None: executor.shutdown(wait=False) def test_properties(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, timeout=5.0, blocking=False, is_singleton=False, executor=executor) assert lock.lock_file == lock_file assert lock.timeout == pytest.approx(5.0) assert lock.blocking is False assert lock.loop is None assert lock.executor is executor executor.shutdown(wait=False) @pytest.mark.asyncio async def test_custom_executor(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) async with lock.read_lock(): assert_mode_held(lock_file, "read") with pytest.raises(RuntimeError, match="not held"): await lock.release() executor.shutdown(wait=False) @pytest.mark.asyncio async def test_close_shuts_down_owned_executor(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor await lock.close() await lock.close() with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.asyncio async def test_close_keeps_provided_executor_open(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) await lock.close() assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=False) @NEEDS_COLLECTED_FINALIZATION def test_del_shuts_down_owned_executor(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor del lock gc.collect() with pytest.raises(RuntimeError): executor.submit(int) def test_del_keeps_provided_executor_open(lock_file: str) -> None: executor = ThreadPoolExecutor(max_workers=1) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) del lock gc.collect() assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=False) @pytest.mark.asyncio async def test_acquire_return_proxy_context_manager(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with await lock.acquire_read() as ctx: assert ctx is lock assert_mode_held(lock_file, "read") with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_nested_context_managers(lock_file: str, mode: Literal["read", "write"]) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) make_ctx = lock.read_lock if mode == "read" else lock.write_lock async with make_ctx(): assert_mode_held(lock_file, mode) async with make_ctx(): assert_mode_held(lock_file, mode) assert_mode_held(lock_file, mode) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.asyncio async def test_context_manager_uses_instance_defaults(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, timeout=3.0, blocking=True, is_singleton=False) async with lock.read_lock(): assert_mode_held(lock_file, "read") async with lock.write_lock(): assert_mode_held(lock_file, "write") await lock.close() @pytest.mark.asyncio async def test_context_manager_overrides_defaults(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) async with lock.read_lock(timeout=5.0, blocking=True): assert_mode_held(lock_file, "read") async with lock.write_lock(timeout=5.0, blocking=True): assert_mode_held(lock_file, "write") await lock.close() @pytest.mark.asyncio async def test_sequential_mode_switch(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) async with lock.read_lock(): pass async with lock.write_lock(): pass async with lock.read_lock(): pass await lock.close() def assert_mode_held(lock_file: str, mode: Literal["read", "write"]) -> None: contender = ReadWriteLock(lock_file, is_singleton=False) acquire = contender.acquire_write if mode == "read" else contender.acquire_read with pytest.raises(Timeout): acquire(blocking=False) contender.close() filelock-3.32.2/tests/test_async_read_write_cancellation.py0000644000000000000000000005137113615410400021131 0ustar00from __future__ import annotations import asyncio import functools import multiprocessing import sqlite3 import sys import threading from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Literal, cast import pytest from filelock import AsyncReadWriteLock, ReadWriteLock from tests.async_filelock_cancellation_helpers import assert_cancellation_message from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from tests.read_write_helpers import assert_read_write_lock_state if TYPE_CHECKING: from collections.abc import Callable from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event as EventType from pathlib import Path from pytest_mock import MockerFixture @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_cancellation_before_executor_start_rolls_back( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: executor_started = threading.Event() release_executor = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() loop = asyncio.get_running_loop() _patch_async_rollback(mocker, loop, rollback_started, finish_rollback) with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) acquire = lock.acquire_read if mode == "read" else lock.acquire_write task = asyncio.create_task(acquire()) await asyncio.sleep(0) task.cancel("first cancellation") release_executor.set() await rollback_started.wait() assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) task.cancel("second cancellation") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task blocker.result(timeout=5) assert_cancellation_message(info.value, "first cancellation") assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() @pytest.mark.parametrize( "cancel_caller", [pytest.param(False, id="executor"), pytest.param(True, id="caller-and-executor")], ) @XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_executor_cancels_queued_acquire_without_compensation(lock_file: str, *, cancel_caller: bool) -> None: context = multiprocessing.get_context("spawn") canceled = context.Value("b", False) process = context.Process(target=_cancel_queued_read_write_acquire, args=(lock_file, cancel_caller, canceled)) process.start() try: process.join(timeout=10) assert (process.exitcode, canceled.value) == (0, True) finally: if process.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails process.terminate() process.join(timeout=5) process.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_caller_cancellation_preserves_cancelled_executor_acquire(lock_file: str) -> None: executor_started = threading.Event() release_executor = threading.Event() executor = ThreadPoolExecutor(max_workers=1) blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, executor=executor) try: task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) task.cancel("caller canceled") await asyncio.sleep(0) executor.shutdown(wait=False, cancel_futures=True) release_executor.set() blocker.result(timeout=5) with pytest.raises(asyncio.CancelledError) as info: await task assert isinstance(info.value.__context__, asyncio.CancelledError) assert_cancellation_message(info.value.__context__, "caller canceled") contender = ReadWriteLock(lock_file, is_singleton=False) try: with contender.write_lock(blocking=False): pass finally: contender.close() finally: release_executor.set() executor.shutdown(wait=True, cancel_futures=True) ReadWriteLock(lock_file).close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_compensation_failure(lock_file: str, mocker: MockerFixture) -> None: executor_started = threading.Event() release_executor = threading.Event() rollback_started = asyncio.Event() finish_rollback = threading.Event() rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback ) with ThreadPoolExecutor(max_workers=1) as executor: blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) task.cancel("cancel acquire") release_executor.set() await rollback_started.wait() finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task blocker.result(timeout=5) assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert cancellation.args == ("cancel acquire",) assert_read_write_lock_state(lock_file, "read", available=False) await lock.release() assert_read_write_lock_state(lock_file, "read", available=True) await lock.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio async def test_acquire_cancellation_while_sqlite_waits_rolls_back( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: context = multiprocessing.get_context("spawn") holder_acquired = context.Event() release_holder = context.Event() holder = context.Process( target=_hold_read_write_lock, args=(lock_file, "write" if mode == "read" else "read", holder_acquired, release_holder), ) holder.start() try: assert holder_acquired.wait(timeout=5), "read-write lock holder did not acquire" execute_started = asyncio.Event() _patch_async_execute_signal(mocker, asyncio.get_running_loop(), execute_started) lock = AsyncReadWriteLock(lock_file, is_singleton=False) task = asyncio.create_task((lock.acquire_read if mode == "read" else lock.acquire_write)()) await execute_started.wait() task.cancel("cancel blocked acquire") release_holder.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "cancel blocked acquire") holder.join(timeout=5) assert not holder.is_alive(), "read-write lock holder did not exit" assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) with pytest.raises(RuntimeError, match="not held"): await lock.release() await lock.close() finally: release_holder.set() if holder.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails holder.terminate() holder.join(timeout=5) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_acquire_and_rollback_errors( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: acquire_started = asyncio.Event() finish_acquire = threading.Event() loop = asyncio.get_running_loop() acquire_error, rollback_error, prior_rollback_error = _patch_async_acquire_and_rollback_failure( mocker, loop, acquire_started, finish_acquire, mode ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) task = asyncio.create_task((lock.acquire_read if mode == "read" else lock.acquire_write)()) await acquire_started.wait() task.cancel("cancel acquire") finish_acquire.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (cancellation.args, cancellation.__context__, acquire_error.__context__) == ( ("cancel acquire",), acquire_error, prior_rollback_error, ) assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) await (lock.acquire_read if mode == "read" else lock.acquire_write)() assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=False) await lock.release() assert_read_write_lock_state(lock_file, "write" if mode == "read" else "read", available=True) await lock.close() @pytest.mark.parametrize("supplied", [pytest.param(False, id="owned"), pytest.param(True, id="supplied")]) @pytest.mark.asyncio async def test_close_cancellation_shuts_down_only_an_owned_executor( lock_file: str, mocker: MockerFixture, supplied: bool ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() _patch_async_rollback(mocker, asyncio.get_running_loop(), rollback_started, finish_rollback) lock = AsyncReadWriteLock( lock_file, is_singleton=False, executor=ThreadPoolExecutor(max_workers=1) if supplied else None ) await lock.acquire_write() executor = lock.executor task = asyncio.create_task(lock.close()) await rollback_started.wait() task.cancel("cancel close") finish_rollback.set() with pytest.raises(asyncio.CancelledError) as info: await task assert_cancellation_message(info.value, "cancel close") assert_read_write_lock_state(lock_file, "read", available=True) if supplied: assert executor.submit(int).result(timeout=5) == 0 executor.shutdown(wait=True) else: with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.parametrize("operation", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancellation_surfaces_rollback_error( lock_file: str, mocker: MockerFixture, operation: Literal["release", "close"] ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) await lock.acquire_write() executor = lock.executor task = asyncio.create_task(lock.release() if operation == "release" else lock.close()) await rollback_started.wait() task.cancel(f"cancel {operation}") finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task assert info.value is rollback_error cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert cancellation.args == (f"cancel {operation}",) assert_read_write_lock_state(lock_file, "read", available=False) assert executor.submit(int).result(timeout=5) == 0 if operation == "release": await lock.release() await lock.close() assert_read_write_lock_state(lock_file, "read", available=True) with pytest.raises(RuntimeError): executor.submit(int) @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_context_cancellation_preserves_body_and_rollback_contexts( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: rollback_started = asyncio.Event() finish_rollback = threading.Event() body_error = ValueError("body failed") prior_error = LookupError("prior rollback failure") rollback_error = _patch_async_rollback_failure( mocker, asyncio.get_running_loop(), rollback_started, finish_rollback, prior_context=prior_error, ) lock = AsyncReadWriteLock(lock_file, is_singleton=False) async def fail_in_context() -> None: async with lock.read_lock() if mode == "read" else lock.write_lock(): raise body_error task = asyncio.create_task(fail_in_context()) await rollback_started.wait() task.cancel("cancel release") finish_rollback.set() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: await task cancellation = rollback_error.__context__ assert isinstance(cancellation, asyncio.CancelledError) assert (info.value, cancellation.args, cancellation.__context__, prior_error.__context__) == ( rollback_error, ("cancel release",), prior_error, body_error, ) probe_mode: Literal["read", "write"] = "write" if mode == "read" else "read" assert_read_write_lock_state(lock_file, probe_mode, available=False) await lock.release() await lock.close() assert_read_write_lock_state(lock_file, probe_mode, available=True) def _patch_async_rollback( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, rollback_started: asyncio.Event, finish_rollback: threading.Event, ) -> None: def rollback(real_connection: sqlite3.Connection) -> None: loop.call_soon_threadsafe(rollback_started.set) assert finish_rollback.wait(timeout=5) real_connection.rollback() _patch_async_connection(mocker, rollback=rollback) def _cancel_queued_read_write_acquire(lock_file: str, cancel_caller: bool, canceled: Synchronized[bool]) -> None: async def cancel_queued_acquire() -> None: executor_started = threading.Event() release_executor = threading.Event() executor = ThreadPoolExecutor(max_workers=1) blocker = executor.submit(_block_executor, executor_started, release_executor) assert executor_started.wait(timeout=5) lock = AsyncReadWriteLock(lock_file, is_singleton=False, executor=executor) task = asyncio.create_task(lock.acquire_write()) await asyncio.sleep(0) if cancel_caller: task.cancel("caller canceled") await asyncio.sleep(0) executor.shutdown(wait=False, cancel_futures=True) release_executor.set() blocker.result(timeout=5) try: await task except asyncio.CancelledError as error: context = error.__context__ if cancel_caller and sys.version_info >= (3, 11): # pragma: >=3.11 cover canceled.value = isinstance(context, asyncio.CancelledError) and context.args == ("caller canceled",) else: canceled.value = True contender = ReadWriteLock(lock_file, is_singleton=False) with contender.write_lock(blocking=False): pass contender.close() asyncio.run(cancel_queued_acquire()) def _patch_async_execute_signal( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, execute_started: asyncio.Event, ) -> None: def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: if "PRAGMA journal_mode" in statement: loop.call_soon_threadsafe(execute_started.set) return real_connection.executescript(statement) _patch_async_connection(mocker, executescript=executescript) def _patch_async_rollback_failure( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, rollback_started: asyncio.Event, finish_rollback: threading.Event, *, prior_context: BaseException | None = None, ) -> sqlite3.OperationalError: rollback_error = sqlite3.OperationalError("rollback failed") rollback_error.__context__ = prior_context rollback_failed = False def rollback(real_connection: sqlite3.Connection) -> None: nonlocal rollback_failed if rollback_failed: real_connection.rollback() return rollback_failed = True loop.call_soon_threadsafe(rollback_started.set) assert finish_rollback.wait(timeout=5) raise rollback_error _patch_async_connection(mocker, rollback=rollback) return rollback_error def _patch_async_acquire_and_rollback_failure( mocker: MockerFixture, loop: asyncio.AbstractEventLoop, acquire_started: asyncio.Event, finish_acquire: threading.Event, mode: Literal["read", "write"], ) -> tuple[sqlite3.OperationalError, sqlite3.OperationalError, LookupError]: acquire_error = sqlite3.OperationalError("acquire failed") rollback_error = sqlite3.OperationalError("rollback failed") prior_rollback_error = LookupError("prior rollback failure") rollback_error.__context__ = prior_rollback_error acquire_failed = False rollback_failed = False def executescript(real_connection: sqlite3.Connection, statement: str) -> sqlite3.Cursor: nonlocal acquire_failed cursor = real_connection.executescript(statement) target = "SELECT name" in statement if mode == "read" else "BEGIN EXCLUSIVE" in statement if target and not acquire_failed: acquire_failed = True loop.call_soon_threadsafe(acquire_started.set) assert finish_acquire.wait(timeout=5) cursor.close() raise acquire_error return cursor def rollback(real_connection: sqlite3.Connection) -> None: nonlocal rollback_failed if not rollback_failed: rollback_failed = True try: raise prior_rollback_error except LookupError: raise rollback_error # ruff:ignore[raise-without-from-inside-except] # exercise implicit backend context preservation real_connection.rollback() _patch_async_connection(mocker, executescript=executescript, rollback=rollback) return acquire_error, rollback_error, prior_rollback_error def _patch_async_connection( mocker: MockerFixture, *, executescript: Callable[[sqlite3.Connection, str], sqlite3.Cursor] | None = None, rollback: Callable[[sqlite3.Connection], None] | None = None, ) -> None: real_connect = sqlite3.connect connection_count = 0 def connect( database: str, *, factory: type[sqlite3.Connection], timeout: float, ) -> sqlite3.Connection: nonlocal connection_count real_connection = real_connect( database, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout, ) connection_count += 1 if connection_count != 2: return real_connection configuration: dict[str, Callable[[str], sqlite3.Cursor] | Callable[[], None]] = {} if executescript is not None: configuration["executescript.side_effect"] = functools.partial(executescript, real_connection) if rollback is not None: configuration["rollback.side_effect"] = functools.partial(rollback, real_connection) connection = mocker.MagicMock(spec_set=type(real_connection), wraps=real_connection, **configuration) mocker.patch.object( type(connection), "in_transaction", new_callable=mocker.PropertyMock, create=True, side_effect=lambda: real_connection.in_transaction, ) return cast("sqlite3.Connection", connection) mocker.patch("filelock._read_write._connect", side_effect=connect) def _block_executor(executor_started: threading.Event, release_executor: threading.Event) -> None: executor_started.set() assert release_executor.wait(timeout=5) def _hold_read_write_lock( lock_file: str, mode: Literal["read", "write"], acquired: EventType, release: EventType, ) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) (lock.acquire_read if mode == "read" else lock.acquire_write)() acquired.set() try: assert release.wait(timeout=10) finally: lock.release() lock.close() filelock-3.32.2/tests/test_async_soft_contracts.py0000644000000000000000000000437413615410400017324 0ustar00from __future__ import annotations import asyncio import os import sys from typing import TYPE_CHECKING import pytest from filelock import AsyncSoftFileLease, AsyncStrictSoftFileLock, LeaseCompromise, Timeout if TYPE_CHECKING: from pathlib import Path _DURATION: float = 0.9 _HEARTBEAT: float = 0.1 @pytest.fixture def marker(tmp_path: Path) -> Path: return tmp_path / "a.lock" @pytest.mark.requires_hard_links @pytest.mark.asyncio async def test_async_strict_treats_a_foreign_marker_as_contention(marker: Path) -> None: # pragma: needs hard-link await asyncio.to_thread(marker.write_text, "filelock/2\npid=999999\nhost=nowhere\nmode=strict\n", encoding="utf-8") lock = AsyncStrictSoftFileLock(str(marker), timeout=0.2) with pytest.raises(Timeout): await lock.acquire() assert await asyncio.to_thread(marker.exists) @pytest.mark.requires_hard_links @pytest.mark.asyncio async def test_async_strict_publishes_a_held_claim(marker: Path) -> None: # pragma: needs hard-link lock = AsyncStrictSoftFileLock(str(marker)) async with lock: held = lock.claims assert tuple(claim.state for claim in held) == ("held", "intent") assert held[0].pid == os.getpid() @pytest.mark.asyncio async def test_async_lease_heartbeat_keeps_a_live_claim(marker: Path) -> None: lease = AsyncSoftFileLease(str(marker), lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT) async with lease: await asyncio.sleep(_DURATION * 1.5) # only a refreshing heartbeat keeps the claim past this assert lease.compromise is None assert lease.token is not None @pytest.mark.skipif( sys.platform == "win32", reason="Windows keeps an open marker undeletable, so no peer can take it from a live holder", ) @pytest.mark.asyncio async def test_async_lease_reports_compromise_when_the_marker_vanishes(marker: Path) -> None: # pragma: win32 no cover seen: list[LeaseCompromise] = [] lease = AsyncSoftFileLease( str(marker), lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=seen.append ) async with lease: # pragma: win32 no cover await asyncio.to_thread(marker.unlink) await asyncio.sleep(_HEARTBEAT * 5) assert [c.reason for c in seen] == ["marker-missing"] filelock-3.32.2/tests/test_async_transition_gate.py0000644000000000000000000000554413615410400017463 0ustar00from __future__ import annotations import asyncio from concurrent.futures import Future as ConcurrentFuture import pytest from filelock._async import _AsyncTransitionGate from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION @pytest.mark.asyncio async def test_hold_waits_for_a_predecessor_before_entering() -> None: gate = _AsyncTransitionGate() order: list[str] = [] first_holding = asyncio.Event() release_first = asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() order.append("first-in") await release_first.wait() order.append("first-out") async def second() -> None: async with gate.hold(): order.append("second-in") first_task = asyncio.create_task(first()) await first_holding.wait() second_task = asyncio.create_task(second()) await asyncio.sleep(0.05) # second parks on the predecessor instead of entering assert order == ["first-in"] release_first.set() await asyncio.gather(first_task, second_task) assert order == ["first-in", "first-out", "second-in"] @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_hold_canceled_while_waiting_lets_the_predecessor_free_the_ticket() -> None: gate = _AsyncTransitionGate() first_holding = asyncio.Event() release_first = asyncio.Event() async def first() -> None: async with gate.hold(): first_holding.set() await release_first.wait() async def second() -> None: async with gate.hold(): # never reached: canceled while waiting on first pass # pragma: no cover # the hold body never runs: the waiter is canceled while parked in hold() first_task = asyncio.create_task(first()) await first_holding.wait() second_task = asyncio.create_task(second()) await asyncio.sleep(0.05) # second is parked on the predecessor second_task.cancel() with pytest.raises(asyncio.CancelledError): await second_task release_first.set() await first_task # The canceled waiter registered a callback so the predecessor frees its abandoned ticket; a fresh holder must # then enter without waiting on a tail that no coroutine will ever leave. entered = asyncio.Event() async def third() -> None: async with gate.hold(): entered.set() await asyncio.wait_for(third(), timeout=1) assert entered.is_set() @pytest.mark.asyncio @pytest.mark.timeout(5) async def test_wait_for_predecessor_returns_immediately_when_already_done() -> None: predecessor: ConcurrentFuture[None] = ConcurrentFuture() predecessor.set_result(None) await _AsyncTransitionGate._wait_for_predecessor( predecessor, blocking=True, cancel_check=None, deadline=None, poll_interval=0.01 ) assert predecessor.done() filelock-3.32.2/tests/test_default_mode.py0000644000000000000000000001030013615410400015506 0ustar00from __future__ import annotations import os import sys from stat import filemode from typing import TYPE_CHECKING import pytest from filelock import FileLock, SoftFileLock from filelock._api import _UNSET_FILE_MODE, BaseFileLock if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, 0o644, id="default"), pytest.param(0o600, 0o600, id="explicit"), ], ) def test_mode_property(lock_type: type[BaseFileLock], mode: int, expected: int, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode).mode == expected @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, False, id="default"), pytest.param(0o644, True, id="explicit"), ], ) def test_has_explicit_mode(lock_type: type[BaseFileLock], mode: int, expected: bool, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode).has_explicit_mode is expected @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("mode", "expected"), [ pytest.param(_UNSET_FILE_MODE, 0o666, id="default"), pytest.param(0o600, 0o600, id="explicit"), ], ) def test_open_mode(lock_type: type[BaseFileLock], mode: int, expected: int, tmp_path: Path) -> None: assert lock_type(tmp_path / "a.lock", mode=mode)._open_mode() == expected @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not apply umask to file permissions") @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_mode_respects_umask(lock_type: type[BaseFileLock], tmp_path: Path) -> None: # pragma: win32 no cover lock_path = tmp_path / "a.lock" lock = lock_type(str(lock_path)) initial_umask = os.umask(0o022) try: # pragma: win32 no cover lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-r--r--" finally: os.umask(initial_umask) lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_default_mode_skips_fchmod(tmp_path: Path, mocker: MagicMock) -> None: # pragma: win32 no cover lock = FileLock(str(tmp_path / "a.lock")) fchmod_spy = mocker.patch("os.fchmod") lock.acquire() assert lock.is_locked fchmod_spy.assert_not_called() lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_explicit_mode_calls_fchmod(tmp_path: Path, mocker: MagicMock) -> None: # pragma: win32 no cover lock = FileLock(str(tmp_path / "a.lock"), mode=0o644) fchmod_spy = mocker.spy(os, "fchmod") lock.acquire() assert lock.is_locked fchmod_spy.assert_called_once() assert fchmod_spy.call_args[0][1] == 0o644 lock.release() @pytest.mark.skipif(sys.platform == "win32", reason="fchmod only on Unix") def test_explicit_mode_overrides_umask(tmp_path: Path) -> None: # pragma: win32 no cover lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0o022) try: # pragma: win32 no cover lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-rw-rw-" finally: os.umask(initial_umask) lock.release() @pytest.mark.parametrize( "mode", [ pytest.param(_UNSET_FILE_MODE, id="default"), pytest.param(0o600, id="explicit"), ], ) def test_singleton_same_mode_matches(mode: int, tmp_path: Path) -> None: lock_path = str(tmp_path / "a.lock") assert FileLock(lock_path, mode=mode, is_singleton=True) is FileLock(lock_path, mode=mode, is_singleton=True) def test_singleton_default_vs_explicit_mode_differ(tmp_path: Path) -> None: lock_path = str(tmp_path / "a.lock") lock = FileLock(lock_path, is_singleton=True) with pytest.raises(ValueError, match="mode"): FileLock(lock_path, mode=0o644, is_singleton=True) del lock def test_unset_file_mode_sentinel_value() -> None: assert _UNSET_FILE_MODE == -1 filelock-3.32.2/tests/test_error.py0000644000000000000000000000237513615410400014224 0ustar00from __future__ import annotations import pickle # ruff:ignore[suspicious-pickle-import] # round-trips Timeout to assert it pickles from typing import TYPE_CHECKING import pytest from filelock import Timeout if TYPE_CHECKING: from collections.abc import Callable @pytest.mark.parametrize( ("extract", "expected"), [ pytest.param(str, "The file lock '/path/to/lock' could not be acquired.", id="str"), pytest.param(repr, "Timeout('/path/to/lock')", id="repr"), pytest.param(lambda t: t.lock_file, "/path/to/lock", id="lock_file"), pytest.param(lambda t: t.__reduce__(), (Timeout, ("/path/to/lock",)), id="reduce"), ], ) def test_timeout_attribute(timeout: Timeout, extract: Callable[[Timeout], object], expected: object) -> None: assert extract(timeout) == expected def test_timeout_pickle(timeout: Timeout) -> None: reloaded = pickle.loads(pickle.dumps(timeout)) # ruff:ignore[suspicious-pickle-usage] # input is the Timeout built in this test assert (type(reloaded), str(reloaded), repr(reloaded), reloaded.lock_file) == ( type(timeout), str(timeout), repr(timeout), timeout.lock_file, ) @pytest.fixture def timeout() -> Timeout: return Timeout("/path/to/lock") filelock-3.32.2/tests/test_filelock.py0000644000000000000000000030522413615410400014662 0ustar00from __future__ import annotations import inspect import logging import os import subprocess # ruff:ignore[suspicious-subprocess-import] # only the fixed-argument, no-shell run below import sys import threading import time import traceback from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from errno import EAGAIN, EINTR, EIO, ENOSPC, ENOSYS, EWOULDBLOCK from inspect import getframeinfo, stack from pathlib import Path, PurePath from stat import S_IMODE, S_IWGRP, S_IWOTH, S_IWUSR, filemode from types import TracebackType from typing import TYPE_CHECKING, Any, Final, Literal from uuid import uuid4 from weakref import WeakValueDictionary import pytest from capabilities import CAPABILITIES from filelock import ( BaseFileLock, ContextErrorPolicy, FileLock, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock, Timeout, UnixFileLock, WindowsFileLock, lock_descriptor, unlock_descriptor, ) from filelock._api import _append_exception_context, _raise_grouped_errors, _registry from tests.capability_marks import ( NEEDS_FCNTL, NEEDS_FILE_MODE, NEEDS_PARENT_SYMLINK_COLLAPSE, NEEDS_PROMPT_FINALIZATION, NEEDS_SYMLINK, ) if sys.version_info >= (3, 11): # pragma: >=3.11 cover from builtins import BaseExceptionGroup, ExceptionGroup # pragma: >=3.11 cover else: # pragma: <3.11 cover from exceptiongroup import BaseExceptionGroup, ExceptionGroup if TYPE_CHECKING: from collections.abc import Callable, Iterator from pytest_mock import MockerFixture @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize("path_type", [str, PurePath, Path]) @pytest.mark.parametrize("filename", ["a", "new/b", "new2/new3/c"]) def test_simple( lock_type: type[BaseFileLock], path_type: type[str | Path], filename: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / filename lock = lock_type(path_type(lock_path)) with lock as locked: assert lock.is_locked assert lock is locked assert not lock.is_locked assert caplog.messages == [ f"Attempting to acquire lock {id(lock)} on {lock_path}", f"Lock {id(lock)} acquired on {lock_path}", f"Attempting to release lock {id(lock)} on {lock_path}", f"Lock {id(lock)} released on {lock_path}", ] assert [r.levelno for r in caplog.records] == [logging.DEBUG, logging.DEBUG, logging.DEBUG, logging.DEBUG] assert [r.name for r in caplog.records] == ["filelock", "filelock", "filelock", "filelock"] assert logging.getLogger("filelock").level == logging.NOTSET @contextmanager def make_ro(path: Path) -> Iterator[None]: write = S_IWUSR | S_IWGRP | S_IWOTH path.chmod(path.stat().st_mode & ~write) try: yield finally: path.chmod(path.stat().st_mode | write) @pytest.fixture def tmp_path_ro(tmp_path: Path) -> Iterator[Path]: # pragma: needs file-mode with make_ro(tmp_path): yield tmp_path @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @NEEDS_FILE_MODE # pragma: needs file-mode @pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="Cannot make a read only file (that the current user: root can't read)", ) def test_ro_folder(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None: lock = lock_type(str(tmp_path_ro / "a")) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire() @pytest.fixture def tmp_file_ro(tmp_path: Path) -> Iterator[Path]: filename = tmp_path / "a" filename.write_text("") with make_ro(filename): yield filename @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="Cannot make a read only file (that the current user: root can't read)", ) def test_ro_file(lock_type: type[BaseFileLock], tmp_file_ro: Path) -> None: lock = lock_type(str(tmp_file_ro)) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire() _WINDOWS_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform != "win32", reason="Windows only") _INVALID_DESCRIPTOR_POLL_INTERVALS: Final = ( pytest.param(0.0, id="zero"), pytest.param(-0.01, id="negative"), pytest.param(float("nan"), id="nan"), pytest.param(float("inf"), id="positive-infinity"), pytest.param(float("-inf"), id="negative-infinity"), ) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) @pytest.mark.parametrize( ("expected_error", "match", "bad_lock_file"), [ # WindowsFileLock raises the real Win32 error the NTSTATUS maps to, so accept its wording alongside os.open's. pytest.param( OSError, "No such file or directory:|cannot find the (path|file)|syntax is incorrect|Access is denied", "", id="blank_filename", ), pytest.param(ValueError, "embedded null (byte|character)", "\0", id="null_byte"), # Should be PermissionError on Windows ( pytest.param(PermissionError, "Permission denied:", ".", id="current_directory") if sys.platform == "win32" # Should be IsADirectoryError on MacOS and Linux else ( pytest.param(IsADirectoryError, "Is a directory", ".", id="current_directory") if sys.platform in {"darwin", "linux"} # Should be some type of OSError at least on other operating systems else pytest.param(OSError, None, ".", id="current_directory") ) ), ] + [ pytest.param(OSError, "Invalid argument|syntax is incorrect", i, id=f"invalid_{i}", marks=_WINDOWS_ONLY) for i in '<>:"|?*\a' ] + [ pytest.param(PermissionError, "Permission denied:", i, id=f"permission_{i}", marks=_WINDOWS_ONLY) for i in "/\\" ], ) @pytest.mark.timeout(5) # timeout in case of infinite loop def test_bad_lock_file( lock_type: type[BaseFileLock], expected_error: type[Exception], match: str, bad_lock_file: str, ) -> None: lock = lock_type(bad_lock_file) with pytest.raises(expected_error, match=match): lock.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_context_manager(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock as lock_1: assert lock.is_locked assert lock is lock_1 with lock as lock_2: assert lock.is_locked assert lock is lock_2 with lock as lock_3: assert lock.is_locked assert lock is lock_3 assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_acquire(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock.acquire() as lock_1: assert lock.is_locked assert lock is lock_1 with lock.acquire() as lock_2: assert lock.is_locked assert lock is lock_2 with lock.acquire() as lock_3: assert lock.is_locked assert lock is lock_3 assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_forced_release(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with lock: assert lock.is_locked lock.acquire() assert lock.is_locked lock.release(force=True) assert not lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_nested_contruct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" with lock_type(str(lock_path), is_singleton=True, timeout=2) as lock_1: assert lock_1.is_locked with lock_type(str(lock_path), is_singleton=True, timeout=2) as lock_2: assert lock_2 is lock_1 assert lock_2.is_locked assert lock_1.is_locked assert not lock_1.is_locked _ExcInfoType = tuple[type[BaseException], BaseException, TracebackType] | tuple[None, None, None] class ExThread(threading.Thread): def __init__(self, target: Callable[[], None], name: str) -> None: super().__init__(target=target, name=name) self.ex: _ExcInfoType | None = None def run(self) -> None: try: super().run() except Exception: # pragma: no cover # a worker that raises fails the test; a healthy run never lands here self.ex = sys.exc_info() def join(self, timeout: float | None = None) -> None: super().join(timeout=timeout) if self.ex is not None: raise RuntimeError from self.ex[1] # pragma: no cover # only a worker that raised gets re-raised here # 100 threads x 100 acquisitions is thousands of lock cycles; the 20s default is tight on a loaded Windows runner. @pytest.mark.timeout(60) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_threaded_shared_lock_obj(lock_type: type[BaseFileLock], tmp_path: Path) -> None: if sys.platform == "win32" and lock_type.__name__ == "SoftFileLock": # pragma: win32 cover pytest.skip( "SoftFileLock uses file-existence locking — on Windows, unlink can silently fail under heavy " "thread contention (EACCES from antivirus/indexer), orphaning the lock file with no recovery path" ) lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) def thread_work() -> None: for _ in range(100): with lock: assert lock.is_locked threads = [ExThread(target=thread_work, name=f"t{i}") for i in range(100)] for thread in threads: thread.start() for thread in threads: thread.join() assert not lock.is_locked @pytest.mark.timeout(60) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_threaded_lock_different_lock_obj(lock_type: type[BaseFileLock], tmp_path: Path) -> None: if sys.platform == "win32" and (hasattr(sys, "pypy_version_info") or lock_type.__name__ == "SoftFileLock"): pytest.skip("SoftFileLock on Windows has race conditions under heavy threading") # pragma: win32 cover def t_1() -> None: for _ in range(1000): with lock_1: assert lock_1.is_locked assert not lock_2.is_locked def t_2() -> None: for _ in range(1000): with lock_2: assert not lock_1.is_locked assert lock_2.is_locked lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) threads = [(ExThread(t_1, f"t1_{i}"), ExThread(t_2, f"t2_{i}")) for i in range(10)] for thread_1, thread_2 in threads: thread_1.start() thread_2.start() for thread_1, thread_2 in threads: thread_1.join() thread_2.join() assert not lock_1.is_locked assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=0.1) assert not lock_2.is_locked assert lock_1.is_locked lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_blocking(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_3 = lock_type(str(lock_path), blocking=False) lock_4 = lock_type(str(lock_path), timeout=0) lock_5 = lock_type(str(lock_path), blocking=False, timeout=-1) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(blocking=False) assert not lock_2.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_3.acquire() assert not lock_3.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_3: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_3.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_4.acquire() assert not lock_4.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_4: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_4.is_locked assert lock_1.is_locked # blocking precedence over timeout with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_5.acquire() assert not lock_5.is_locked assert lock_1.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."), lock_5: pass # pragma: no cover # __enter__ raises Timeout, so the body never runs assert not lock_5.is_locked assert lock_1.is_locked lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked assert not lock_3.is_locked assert not lock_4.is_locked assert not lock_5.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_timeout(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path), timeout=0.1) assert lock_2.timeout == pytest.approx(0.1) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire() assert not lock_2.is_locked assert lock_1.is_locked lock_2.timeout = 0 assert lock_2.timeout == 0 with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire() assert not lock_2.is_locked assert lock_1.is_locked lock_1.release() assert not lock_1.is_locked assert not lock_2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_context_release_on_exc(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) try: with lock as lock_1: assert lock is lock_1 assert lock.is_locked raise ValueError # ruff:ignore[raise-within-try] # raises inside the block to assert release on exception except ValueError: assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_acquire_release_on_exc(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) try: with lock.acquire() as lock_1: assert lock is lock_1 assert lock.is_locked raise ValueError # ruff:ignore[raise-within-try] # raises inside the block to assert release on exception except ValueError: assert not lock.is_locked @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_del(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1, lock_2 = lock_type(str(lock_path)), lock_type(str(lock_path)) lock_1.acquire() assert lock_1.is_locked assert not lock_2.is_locked with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=0.1) del lock_1 lock_2.acquire() assert lock_2.is_locked lock_2.release() def test_cleanup_soft_lock(tmp_path: Path) -> None: lock_path = tmp_path / "a" with SoftFileLock(lock_path): assert lock_path.exists() assert not lock_path.exists() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_intervall_deprecated(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) with pytest.deprecated_call(match="use poll_interval instead of poll_intervall") as checker: lock.acquire(poll_intervall=0.05) frame_info = getframeinfo(stack()[0][0]) # lineno here is one past the acquire() call above for warning in checker: if warning.filename == frame_info.filename and warning.lineno + 1 == frame_info.lineno: # pragma: no branch break else: # pragma: no cover # the stacklevel=2 warning is always found, so the failure arm never runs pytest.fail("No warnings of stacklevel=2 matching.") # A held lock left to the garbage collector releases at an arbitrary later point on a deferred collector, and its # release logs then land in whichever test is running. lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_default_poll_interval(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) assert lock.poll_interval == pytest.approx(0.05) lock_2 = lock_type(str(lock_path), poll_interval=0.1) assert lock_2.poll_interval == pytest.approx(0.1) lock_2.poll_interval = 0.2 assert lock_2.poll_interval == pytest.approx(0.2) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_interval_used_by_context_manager( lock_type: type[BaseFileLock], tmp_path: Path, mocker: MockerFixture ) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path), timeout=0.2, poll_interval=0.05) lock_1.acquire() sleep_mock = mocker.patch("filelock._api.time.sleep") with pytest.raises(Timeout): lock_2.acquire() sleep_mock.assert_called_with(0.05) sleep_mock.reset_mock() lock_2.poll_interval = 0.1 with pytest.raises(Timeout): lock_2.acquire() sleep_mock.assert_called_with(0.1) lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_poll_interval_acquire_override(lock_type: type[BaseFileLock], tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path), timeout=0.2, poll_interval=0.05) lock_1.acquire() sleep_mock = mocker.patch("filelock._api.time.sleep") with pytest.raises(Timeout): lock_2.acquire(poll_interval=0.15) sleep_mock.assert_called_with(0.15) lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_context_decorator(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) @lock def decorated_method() -> None: assert lock.is_locked assert not lock.is_locked decorated_method() assert not lock.is_locked def test_lock_mode(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0o022) # pin umask so the resulting permissions are predictable try: lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == "-rw-rw-rw-" finally: os.umask(initial_umask) lock.release() def test_lock_mode_soft(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = SoftFileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0o022) # pin umask so the resulting permissions are predictable try: lock.acquire() assert lock.is_locked assert filemode(lock_path.stat().st_mode) == ("-rw-rw-rw-" if sys.platform == "win32" else "-rw-r--r--") finally: os.umask(initial_umask) lock.release() def test_umask(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = FileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0) os.umask(initial_umask) lock.acquire() assert lock.is_locked current_umask = os.umask(0) os.umask(current_umask) assert initial_umask == current_umask lock.release() def test_umask_soft(tmp_path: Path) -> None: lock_path = tmp_path / "a.lock" lock = SoftFileLock(str(lock_path), mode=0o666) initial_umask = os.umask(0) os.umask(initial_umask) lock.acquire() assert lock.is_locked current_umask = os.umask(0) os.umask(current_umask) assert initial_umask == current_umask lock.release() def test_wrong_platform(tmp_path: Path) -> None: assert not inspect.isabstract(UnixFileLock) assert not inspect.isabstract(WindowsFileLock) assert inspect.isabstract(BaseFileLock) lock_type = UnixFileLock if sys.platform == "win32" else WindowsFileLock lock = lock_type(tmp_path / "lockfile") with pytest.raises(NotImplementedError): lock.acquire() with pytest.raises(NotImplementedError): lock._release() @NEEDS_FCNTL # pragma: needs fcntl @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") def test_flock_not_implemented_unix(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("fcntl.flock", side_effect=OSError(ENOSYS, "mock error")) lock = FileLock(tmp_path / "a.lock") with lock: assert lock.is_locked assert isinstance(lock, SoftFileLock) def test_soft_errors(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("os.open", side_effect=OSError(ENOSYS, "mock error")) with pytest.raises(OSError, match="mock error"): SoftFileLock(tmp_path / "a.lock").acquire() def _check_file_read_write(txt_file: Path) -> None: for _ in range(3): uuid = str(uuid4()) txt_file.write_text(uuid, encoding="utf-8") assert txt_file.read_text(encoding="utf-8") == uuid @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_passing_lock_to_threads(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: def mess_with_file(lock_: BaseFileLock) -> None: with lock_: _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" lock = lock_type(lock_file) with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file, lock) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_global_lock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: def mess_with_file() -> None: with lock: _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" lock = lock_type(lock_file) with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thrashing_with_thread_pool_lock_recreated_in_each_thread( tmp_path: Path, lock_type: type[BaseFileLock], ) -> None: def mess_with_file() -> None: with lock_type(lock_file): _check_file_read_write(txt_file) lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt" with ThreadPoolExecutor() as executor: results = [executor.submit(mess_with_file) for _ in range(100)] assert all(r.result() is None for r in results) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_lock_can_be_non_thread_local( tmp_path: Path, lock_type: type[BaseFileLock], ) -> None: lock = lock_type(tmp_path / "test.lock", thread_local=False) for _ in range(2): thread = threading.Thread(target=lock.acquire, kwargs={"timeout": 2}) thread.start() thread.join() assert lock.lock_counter == 2 lock.release(force=True) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_thread_local_setter_visibility(lock_type: type[BaseFileLock], tmp_path: Path) -> None: """Property setters stay per-thread when thread_local=True. A ``poll_interval`` set on the constructing thread stays invisible to other threads: ``threading.local`` re-applies the constructor arguments the first time each new thread touches the context, so the reader sees the default. """ lock = lock_type(tmp_path / "x.lock", thread_local=True, poll_interval=0.05) lock.poll_interval = 0.5 observed: list[float] = [] def read_from_thread() -> None: observed.append(lock.poll_interval) t = threading.Thread(target=read_from_thread) t.start() t.join() assert observed == [pytest.approx(0.05)] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_thread_local_setter_visibility(lock_type: type[BaseFileLock], tmp_path: Path) -> None: """With thread_local=False, property setters are visible across threads.""" lock = lock_type(tmp_path / "x.lock", thread_local=False, poll_interval=0.05) lock.poll_interval = 0.5 observed: list[float] = [] t = threading.Thread(target=lambda: observed.append(lock.poll_interval)) t.start() t.join() assert observed == [pytest.approx(0.5)] def test_subclass_compatibility(tmp_path: Path) -> None: class MyFileLock(FileLock): def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, my_param: int = 0, **kwargs: dict[str, Any], # ruff:ignore[unused-method-argument] # matches the base constructor signature; extras are dropped ) -> None: super().__init__(lock_file, timeout, mode, thread_local, is_singleton=True) self.blocking = True self.my_param = my_param lock_path = tmp_path / "a" MyFileLock(str(lock_path), my_param=1) class MySoftFileLock(SoftFileLock): def __init__( self, lock_file: str | os.PathLike[str], timeout: float = -1, mode: int = 0o644, thread_local: bool = True, my_param: int = 0, **kwargs: dict[str, Any], # ruff:ignore[unused-method-argument] # matches the base constructor signature; extras are dropped ) -> None: super().__init__(lock_file, timeout, mode, thread_local, blocking=True, is_singleton=True) self.my_param = my_param MySoftFileLock(str(lock_path), my_param=1) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_and_non_singleton_locks_are_distinct(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path), is_singleton=False) assert lock_1.is_singleton is False lock_2 = lock_type(str(lock_path), is_singleton=True) assert lock_2.is_singleton is True assert lock_2 is not lock_1 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_the_same(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path), is_singleton=True) lock_2 = lock_type(str(lock_path), is_singleton=True) assert lock_2 is lock_1 def test_singleton_locks_survive_concurrent_first_construction(tmp_path: Path) -> None: # Two threads constructing the same is_singleton=True lock at once must still share one instance. A slow # __init__ widens the window between the cache miss and the store so the race is hit deterministically. lock_path = tmp_path / "a" class _SlowLock(SoftFileLock): def __init__(self, lock_file: str, *, is_singleton: bool = True) -> None: time.sleep(0.05) super().__init__(lock_file, is_singleton=is_singleton) results: list[BaseFileLock] = [] barrier = threading.Barrier(2) def build() -> None: barrier.wait() results.append(_SlowLock(str(lock_path), is_singleton=True)) threads = [threading.Thread(target=build) for _ in range(2)] for thread in threads: thread.start() for thread in threads: thread.join() assert results[0] is results[1] @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_distinct_per_lock_file(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path_1 = tmp_path / "a" lock_1 = lock_type(str(lock_path_1), is_singleton=True) lock_path_2 = tmp_path / "b" lock_2 = lock_type(str(lock_path_2), is_singleton=True) assert lock_1 is not lock_2 @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_must_be_initialized_with_the_same_args(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" args: dict[str, Any] = {"timeout": -1, "mode": 0o644, "thread_local": True, "blocking": True, "poll_interval": 0.05} alternate_args: dict[str, Any] = { "timeout": 10, "mode": 0, "thread_local": False, "blocking": False, "poll_interval": 0.1, } lock = lock_type(str(lock_path), is_singleton=True, **args) general_msg = "Singleton lock instances cannot be initialized with differing arguments" for arg_name in args: altered_args = args.copy() altered_args[arg_name] = alternate_args[arg_name] with pytest.raises(ValueError, match=general_msg) as exc_info: lock_type(str(lock_path), is_singleton=True, **altered_args) exc_info.match(arg_name) # ensure specific non-matching argument is included in exception text del lock, exc_info @NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_deleted_when_no_external_references_exist( lock_type: type[BaseFileLock], tmp_path: Path, ) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path), is_singleton=True) assert lock_type._instances == {str(lock_path): lock} del lock assert lock_type._instances == {} @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_instance_tracking_is_unique_per_subclass(lock_type: type[BaseFileLock]) -> None: class Lock1(lock_type): # ty: ignore[unsupported-base] pass class Lock2(lock_type): # ty: ignore[unsupported-base] pass assert isinstance(Lock1._instances, WeakValueDictionary) assert isinstance(Lock2._instances, WeakValueDictionary) assert Lock1._instances is not Lock2._instances def test_singleton_locks_when_inheriting_init_is_called_once(tmp_path: Path) -> None: init_calls = 0 class MyFileLock(FileLock): def __init__(self, *args: Any, **kwargs: Any) -> None: # ruff:ignore[any-type] # forwards arbitrary constructor arguments to super().__init__ super().__init__(*args, **kwargs) nonlocal init_calls init_calls += 1 lock_path = tmp_path / "a" lock1 = MyFileLock(str(lock_path), is_singleton=True) lock2 = MyFileLock(str(lock_path), is_singleton=True) assert lock1 is lock2 assert init_calls == 1 def test_file_lock_positional_argument(tmp_path: Path) -> None: class FilePathLock(FileLock): def __init__(self, file_path: str) -> None: super().__init__(file_path + ".lock") lock_path = tmp_path / "a" lock = FilePathLock(str(lock_path)) assert lock.lock_file == str(lock_path) + ".lock" @pytest.mark.skipif(sys.platform != "win32" and os.geteuid() == 0, reason="root can open a 0o444 file for writing") @pytest.mark.parametrize("lock_type", [SoftFileLock, FileLock]) def test_readonly_lock_file_with_mtime_zero_raises(lock_type: type[BaseFileLock], tmp_path: Path) -> None: # acquire() no longer short-circuits the writability check on mtime 0, so a read-only lock file is still rejected. lock_path = tmp_path / "z.lock" lock_path.touch() lock_path.chmod(0o444) os.utime(lock_path, (0, 0)) try: with pytest.raises(PermissionError): lock_type(str(lock_path)).acquire(timeout=0) finally: lock_path.chmod(0o644) @pytest.mark.parametrize("lock_type", [SoftFileLock]) def test_lock_file_removed_after_release(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock = lock_type(str(lock_path)) with lock: assert lock_path.exists() assert not lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_concurrent_acquire_release_keeps_lock_file(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" errors: list[Exception] = [] def worker() -> None: try: for _ in range(20): lock = FileLock(str(lock_path), is_singleton=False) with lock: pass except Exception as exc: # pragma: no cover # a healthy run never raises here errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(4)] for thread in threads: thread.start() for thread in threads: thread.join(timeout=30) assert not errors, errors assert lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_lock_acquired_after_release_keeps_path(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" first = FileLock(str(lock_path), is_singleton=False) second = FileLock(str(lock_path), is_singleton=False) first.acquire() assert lock_path.exists() first.release() assert lock_path.exists() second.acquire() assert lock_path.exists() second.release() assert lock_path.exists() @NEEDS_FCNTL # pragma: needs fcntl def test_waiter_fd_cannot_split_lock_after_release(tmp_path: Path) -> None: # typeshed hides fcntl's members off POSIX, so state the invariant the capability gate already enforces. assert sys.platform != "win32" import fcntl lock_path = tmp_path / "test.lock" first = FileLock(str(lock_path), is_singleton=False) replacement = FileLock(str(lock_path), is_singleton=False) first.acquire() waiter_fd = os.open(lock_path, os.O_RDWR) try: first.release() assert lock_path.exists() replacement.acquire() try: with pytest.raises(BlockingIOError) as exc_info: fcntl.flock(waiter_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) assert exc_info.value.errno in {EAGAIN, EWOULDBLOCK} finally: replacement.release() finally: os.close(waiter_fd) @NEEDS_FCNTL # pragma: needs fcntl def test_stale_inode_retry_on_unlinked_lock(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_fstat = os.fstat call_count = 0 def fstat_unlinked_once(fd: int) -> os.stat_result: nonlocal call_count call_count += 1 if call_count == 1: Path(lock_path).unlink() return real_fstat(fd) return real_fstat(fd) mocker.patch("os.fstat", side_effect=fstat_unlinked_once) lock.acquire() assert lock.is_locked assert call_count == 2 lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_permission_error_fallback_without_o_creat(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open call_count = 0 def open_no_creat(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal call_count call_count += 1 if call_count == 1 and flags & os.O_CREAT: raise PermissionError(13, "Permission denied", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_no_creat) lock.acquire() assert lock.is_locked assert call_count == 2 lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_permission_error_propagates_when_file_missing(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open def open_always_permission_error(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if "test.lock" in path: raise PermissionError(13, "Permission denied", path) return ( # pragma: no cover # every os.open during this acquire targets the lock path real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) ) mocker.patch("os.open", side_effect=open_always_permission_error) with pytest.raises(PermissionError, match="Permission denied"): lock.acquire(timeout=0) @NEEDS_FCNTL # pragma: needs fcntl def test_sticky_bit_fallback_handles_concurrent_unlink(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open denied_create = False unlinked_open = False # Key the injections off each open's signature rather than a global call count: GraalPy opens unrelated paths # mid-acquire, which would shift a counter and land the errors on the wrong opens. Deny the first create, then # unlink under the first no-create open, so the sticky-bit fallback must retry once more to succeed. def open_permission_then_unlink(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal denied_create, unlinked_open if not denied_create and flags & os.O_CREAT and "test.lock" in path: denied_create = True raise PermissionError(13, "Permission denied", path) if not unlinked_open and not flags & os.O_CREAT and "test.lock" in path: unlinked_open = True lock_path.unlink(missing_ok=True) raise FileNotFoundError(2, "No such file or directory", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_permission_then_unlink) lock.acquire() assert lock.is_locked assert (denied_create, unlinked_open) == (True, True) lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_triggers(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() with pytest.raises(Timeout, match=r"The file lock '.*' could not be acquired."): lock_2.acquire(timeout=1, cancel_check=lambda: True) assert not lock_2.is_locked lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_after_n_polls(lock_type: type[BaseFileLock], tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() call_count = 0 def cancel_after_two() -> bool: nonlocal call_count call_count += 1 return call_count >= 2 mocker.patch("filelock._api.time.sleep") with pytest.raises(Timeout): lock_2.acquire(timeout=10, cancel_check=cancel_after_two) assert call_count == 2 assert not lock_2.is_locked lock_1.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_not_called_when_lock_available(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) called = False def should_not_be_called() -> bool: # pragma: no cover # a free lock never consults cancel_check nonlocal called called = True return True lock.acquire(cancel_check=should_not_be_called) assert lock.is_locked assert not called lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_false_allows_acquisition(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" lock = lock_type(str(lock_path)) lock.acquire(cancel_check=lambda: False) assert lock.is_locked lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_cancel_check_log_message( lock_type: type[BaseFileLock], tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.DEBUG) lock_path = tmp_path / "a" lock_1 = lock_type(str(lock_path)) lock_2 = lock_type(str(lock_path)) lock_1.acquire() with pytest.raises(Timeout): lock_2.acquire(timeout=1, cancel_check=lambda: True) assert any("Cancellation requested" in msg for msg in caplog.messages) lock_1.release() @NEEDS_FCNTL # pragma: needs fcntl def test_filenotfound_on_fuse_nfs_retries(tmp_path: Path, mocker: MockerFixture) -> None: """Retry recovers from the FUSE/NFS os.open(O_CREAT) race that raises FileNotFoundError.""" lock_path = tmp_path / "test.lock" lock = FileLock(str(lock_path), is_singleton=False) real_open = os.open call_count = 0 def open_enoent_then_succeed(path: str, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal call_count call_count += 1 if call_count == 1 and flags & os.O_CREAT and "test.lock" in path: # first O_CREAT hits the FUSE/NFS ENOENT raise FileNotFoundError(2, "No such file or directory", path) return real_open(path, flags, mode) if dir_fd is None else real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("os.open", side_effect=open_enoent_then_succeed) lock.acquire() assert lock.is_locked assert call_count >= 2 lock.release() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_same_thread_different_instances_raises(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path) with pytest.raises(RuntimeError, match="Deadlock"): lock2.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_finite_timeout_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path, timeout=0.1) with pytest.raises(Timeout): lock2.acquire() @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_non_blocking_gives_timeout_not_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock2 = lock_type(lock_path, blocking=False) with pytest.raises(Timeout): lock2.acquire() @pytest.mark.parametrize( "mode", [pytest.param("finite", id="finite"), pytest.param("nonblocking", id="nonblocking")], ) def test_failed_acquire_keeps_holder_registered(tmp_path: Path, mode: Literal["finite", "nonblocking"]) -> None: lock_path = tmp_path / "test.lock" with FileLock(lock_path): with pytest.raises(Timeout): _acquire_for_mode(FileLock(lock_path), mode) with pytest.raises(RuntimeError, match="Deadlock"): FileLock(lock_path).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_different_paths_no_conflict(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock1 = lock_type(tmp_path / "a.lock") lock2 = lock_type(tmp_path / "b.lock") with lock1, lock2: assert lock1.is_locked assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_same_instance_reentrant_works(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock = lock_type(tmp_path / "test.lock") with lock: with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_avoids_deadlock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, is_singleton=True) with lock1: lock2 = lock_type(lock_path, is_singleton=True) assert lock1 is lock2 with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_different_threads_no_false_positive(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path, timeout=0) lock1.acquire() error: BaseException | None = None def acquire_in_thread() -> None: nonlocal error lock2 = lock_type(lock_path, timeout=0) try: lock2.acquire() except BaseException as exc: error = exc thread = threading.Thread(target=acquire_in_thread) thread.start() thread.join() lock1.release() assert not isinstance(error, RuntimeError), "Should not raise RuntimeError in different thread" _NEEDS_O_NOFOLLOW: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["o-nofollow"], reason="refusing to open through a final symlink needs a honored O_NOFOLLOW" ) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_symlink_same_canonical_path(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: # A symlinked parent directory resolves to the same canonical key (the final component is kept literal, so a final # symlink stays distinct — see test_final_symlink_stays_a_distinct_key). real_dir = tmp_path / "real" real_dir.mkdir() (tmp_path / "link").symlink_to(real_dir) lock1 = lock_type(str(real_dir / "test.lock")) with lock1: lock2 = lock_type(str(tmp_path / "link" / "test.lock")) with pytest.raises(RuntimeError, match="Deadlock"): lock2.acquire() @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover @pytest.mark.parametrize( ("depth", "force"), [ pytest.param(1, False, id="direct"), pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced"), ], ) def test_release_drops_acquisition_key_after_parent_retarget(tmp_path: Path, depth: int, force: bool) -> None: lock_path, original_path, replacement_path = _symlinked_lock_paths(tmp_path) lock = FileLock(lock_path) for _depth in range(depth): lock.acquire() _retarget_parent(lock_path, replacement_path) if force: lock.release(force=True) else: for _depth in range(depth): lock.release() with FileLock(original_path) as successor: assert successor.is_locked @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_release_keeps_retargeted_parent_holder_registered(tmp_path: Path) -> None: lock_path, _original_path, replacement_path = _symlinked_lock_paths(tmp_path) original = FileLock(lock_path) original.acquire() _retarget_parent(lock_path, replacement_path) with FileLock(replacement_path): original.release() with pytest.raises(RuntimeError, match="Deadlock"): FileLock(replacement_path).acquire(cancel_check=lambda: True) @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_deadlock_registry_cleanup_on_release(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) lock1.acquire() lock1.release() lock2 = lock_type(lock_path) with lock2: assert lock2.is_locked @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_force_release_cleans_registry(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock_path = tmp_path / "test.lock" lock1 = lock_type(lock_path) with lock1: lock1.acquire() assert lock1.lock_counter == 2 lock1.release(force=True) lock2 = lock_type(lock_path) with lock2: assert lock2.is_locked def _symlinked_lock_paths(tmp_path: Path) -> tuple[Path, Path, Path]: # pragma: win32 no cover original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) return link / "test.lock", original / "test.lock", replacement / "test.lock" def _retarget_parent(lock_path: Path, replacement_path: Path) -> None: # pragma: win32 no cover link = lock_path.parent link.unlink() link.symlink_to(replacement_path.parent, target_is_directory=True) def _acquire_for_mode(lock: BaseFileLock, mode: Literal["finite", "nonblocking"]) -> None: if mode == "finite": lock.acquire(timeout=0) else: lock.acquire(blocking=False) @pytest.fixture def held_lock_path(tmp_path: Path) -> Iterator[Path]: # pragma: needs fcntl path = tmp_path / "resource.lock" with FileLock(path, timeout=0): yield path @NEEDS_FCNTL # pragma: needs fcntl def test_winner_truncates_stale_content(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_text("stale from a previous holder", encoding="utf-8") with FileLock(lock_path, timeout=0): assert lock_path.stat().st_size == 0 @NEEDS_FCNTL # pragma: needs fcntl def test_contender_preserves_holder_contents(held_lock_path: Path) -> None: held_lock_path.write_text("holder metadata", encoding="utf-8") with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0).acquire() assert held_lock_path.read_text(encoding="utf-8") == "holder metadata" @NEEDS_FCNTL # pragma: needs fcntl def test_contender_preserves_holder_mode(held_lock_path: Path) -> None: held_lock_path.chmod(0o600) with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0, mode=0o644).acquire() assert S_IMODE(held_lock_path.stat().st_mode) == 0o600 @NEEDS_FCNTL # pragma: needs fcntl def test_contender_does_not_fchmod(held_lock_path: Path, mocker: MockerFixture) -> None: fchmod_spy = mocker.spy(os, "fchmod") with pytest.raises(Timeout): FileLock(held_lock_path, timeout=0, mode=0o644).acquire() fchmod_spy.assert_not_called() @NEEDS_FCNTL # pragma: needs fcntl def test_post_lock_truncate_failure_closes_fd(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("os.ftruncate", side_effect=OSError(ENOSPC, "No space left on device")) open_spy = mocker.spy(os, "open") close_spy = mocker.spy(os, "close") with pytest.raises(OSError, match="No space left on device"): FileLock(tmp_path / "resource.lock", timeout=0).acquire() assert any(call.args and call.args[0] == open_spy.spy_return for call in close_spy.call_args_list) # Only a Windows run that also holds the symlink privilege reaches this body, so both exclusions apply. @_WINDOWS_ONLY # pragma: win32 cover # pragma: needs symlink @NEEDS_SYMLINK def test_windows_reparse_point_lock_file_rejected(tmp_path: Path) -> None: target = tmp_path / "target.txt" target.write_text("sensitive", encoding="utf-8") link = tmp_path / "resource.lock" link.symlink_to(target) with pytest.raises(OSError, match="reparse point"): FileLock(link).acquire() assert target.read_text(encoding="utf-8") == "sensitive" def test_release_keeps_lock_until_final_hold(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() lock.acquire() lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked assert lock.lock_counter == 0 def test_forced_release_drops_all_holds(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() lock.acquire() lock.release(force=True) assert not lock.is_locked assert lock.lock_counter == 0 @NEEDS_FCNTL # pragma: needs fcntl def test_unix_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_unix_context_exit_propagates_unlock_failure(tmp_path: Path, mocker: MockerFixture) -> None: # One LOCK_EX for acquire, then one LOCK_UN per release; fail only the first unlock so __exit__ propagates it. mocker.patch("filelock._unix.fcntl.flock", side_effect=[None, OSError(EIO, "unlock failed"), None]) lock = FileLock(str(tmp_path / "a")) with pytest.raises(OSError, match="unlock failed"), lock: assert lock.is_locked assert lock.is_locked lock.release() assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_windows_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() mocker.patch("filelock._windows._unlock_fd", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked assert lock.lock_counter == 1 lock.release() assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_windows_close_failure_still_commits_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) lock.acquire() # The OS unlock succeeds, so the lock is released; the later close failure propagates but must not leave the # counter or registry believing the lock is still held. mocker.patch("filelock._windows.os.close", side_effect=OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): lock.release() assert not lock.is_locked assert lock.lock_counter == 0 @_WINDOWS_ONLY # pragma: win32 cover def test_windows_delete_pending_is_treated_as_contention(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._windows._nt_open", return_value=(0, 0xC0000056)) # STATUS_DELETE_PENDING with pytest.raises(Timeout): FileLock(str(tmp_path / "a"), timeout=0.2).acquire() @_WINDOWS_ONLY # pragma: win32 cover def test_windows_permanent_denial_raises_without_timeout(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._windows._nt_open", return_value=(0, 0xC0000022)) # STATUS_ACCESS_DENIED with pytest.raises(PermissionError): FileLock(str(tmp_path / "a"), timeout=5).acquire() @_WINDOWS_ONLY # pragma: win32 cover def test_windows_delete_in_progress_is_contention_not_denial(tmp_path: Path) -> None: # The marker skips the run; this only narrows ctypes.windll for ty, so it goes one way. if sys.platform == "win32": # pragma: no branch import ctypes target = str(tmp_path / "dp.lock") kernel32 = ctypes.windll.kernel32 delete_access, generic_read, share_all, create_always, delete_on_close = ( 0x10000, 0x80000000, 0x1 | 0x2 | 0x4, 2, 0x04000000, ) # Open with delete-on-close so the name is marked for deletion but the live handle keeps it around: a fresh # open sees the deletion in progress (delete-pending or a sharing violation), which acquire must retry rather # than mistake for a permanent PermissionError. handle = kernel32.CreateFileW( target, delete_access | generic_read, share_all, None, create_always, delete_on_close, None ) assert handle not in {0, ctypes.c_void_p(-1).value} try: with pytest.raises(Timeout): FileLock(target, timeout=0.3).acquire() finally: kernel32.CloseHandle(handle) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure body_error = ValueError("body failed") body_cause = LookupError("body cause") lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock.acquire() if use_proxy else lock: raise body_error from body_cause assert ( info.value.exceptions, body_error.__context__, release_error.__context__, body_error.__cause__, release_error.__cause__, body_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((body_error, release_error), None, None, body_cause, release_cause, True, True) def test_context_group_handles_deep_body_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") context: BaseException = body_error for index in range(2_000): next_context = RuntimeError(str(index)) context.__context__ = next_context context = next_context lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock: raise body_error assert (info.value.exceptions, release_error.__context__) == ((body_error, release_error), None) def test_context_group_detaches_equivalent_shared_exception_dag( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error: BaseException = ValueError("shared leaf") context_error = body_error for depth in range(25): body_error = ExceptionGroup(str(depth), (body_error, body_error)) context_error = ExceptionGroup(str(depth), (context_error, context_error)) lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) try: raise context_error except ExceptionGroup: with pytest.raises(ExceptionGroup) as info, lock: raise body_error # ruff:ignore[raise-without-from-inside-except] # build the caller-controlled implicit context graph assert (info.value.exceptions, body_error.__context__, release_error.__context__) == ( (body_error, release_error), None, None, ) def test_context_group_preserves_distinct_shared_exception_dag( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], ) -> None: capture, release_error, _ = close_failure body_error: BaseException = ValueError("body leaf") context_error: BaseException = TypeError("context leaf") for depth in range(25): body_error = ExceptionGroup(str(depth), (body_error, body_error)) context_error = ExceptionGroup(str(depth), (context_error, context_error)) lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) try: raise context_error except ExceptionGroup: with pytest.raises(ExceptionGroup) as info, lock: raise body_error # ruff:ignore[raise-without-from-inside-except] # build the caller-controlled implicit context graph assert (info.value.exceptions, body_error.__context__, release_error.__context__) == ( (body_error, release_error), context_error, None, ) @pytest.mark.skipif( sys.version_info < (3, 11), reason="standard exception-group rendering requires Python 3.11" ) # pragma: >=3.11 cover @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_group_renders_independent_leaves( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure release_error.__cause__ = None release_error.__suppress_context__ = False body_error = ValueError("body failed") lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(ExceptionGroup) as info, lock.acquire() if use_proxy else lock: raise body_error group_rendering = "".join(traceback.format_exception(info.value)) release_rendering = "".join(traceback.format_exception(release_error)) assert ( group_rendering.count("ValueError: body failed"), release_rendering.count("ValueError: body failed"), release_rendering.count("RuntimeError: release cause"), release_rendering.count("OSError: release failed"), ) == (1, 0, 0, 1) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_chain_keeps_release_error_with_body_in_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], *, use_proxy: bool, ) -> None: capture, release_error, _ = close_failure body_error = ValueError("body failed") lock = FileLock(str(tmp_path / "a"), context_error_policy="chain", close_error_policy="raise", on_acquired=capture) with pytest.raises(OSError, match="release failed") as info, lock.acquire() if use_proxy else lock: raise body_error assert (info.value, release_error.__context__) == (release_error, body_error) @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_body_only_failure_propagates_body( tmp_path: Path, policy: ContextErrorPolicy, *, use_proxy: bool ) -> None: lock = SoftFileLock(str(tmp_path / "a"), context_error_policy=policy) body = ValueError("body failed") with pytest.raises(ValueError, match="body failed") as info, lock.acquire() if use_proxy else lock: raise body assert info.value is body @pytest.mark.parametrize( "policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")], ) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_release_only_failure_propagates_release( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError], policy: ContextErrorPolicy, *, use_proxy: bool, ) -> None: capture, release_error, release_cause = close_failure lock = FileLock(str(tmp_path / "a"), context_error_policy=policy, close_error_policy="raise", on_acquired=capture) with pytest.raises(OSError, match="release failed") as info, lock.acquire() if use_proxy else lock: pass assert (info.value, release_error.__context__, release_error.__cause__) == (release_error, None, release_cause) def test_context_group_base_exception_leaf_is_base_group( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, _, _ = close_failure lock = FileLock(str(tmp_path / "a"), context_error_policy="group", close_error_policy="raise", on_acquired=capture) with pytest.raises(BaseExceptionGroup) as info, lock: raise KeyboardInterrupt assert not isinstance(info.value, ExceptionGroup) # a BaseException leaf stays outside except Exception assert [type(leaf) for leaf in info.value.exceptions] == [KeyboardInterrupt, OSError] def test_invalid_context_error_policy_rejected(tmp_path: Path) -> None: with pytest.raises(ValueError, match="context_error_policy must be"): SoftFileLock(str(tmp_path / "a"), context_error_policy="explode") # ty: ignore[invalid-argument-type] def test_singleton_rejects_different_context_policy(tmp_path: Path) -> None: path = str(tmp_path / "a") first = SoftFileLock(path, is_singleton=True, context_error_policy="chain") try: with pytest.raises(ValueError, match="context_error_policy"): SoftFileLock(path, is_singleton=True, context_error_policy="group") finally: first.release(force=True) def _fail_close_of(mocker: MockerFixture, lock: BaseFileLock, error: OSError) -> list[int]: # Fail os.close only for this lock's own descriptor, and record each attempt on it. A blanket patch would also hit # the close another test's lock runs from __del__ during this test, turning an unrelated garbage collection into a # spurious failure. Returns the list of attempts so a caller can assert close is not retried. fd = lock._context.lock_file_fd real_close = os.close attempts: list[int] = [] def close(target: int) -> None: if target == fd: attempts.append(target) raise error real_close(target) # pragma: no cover # only an unrelated __del__ close during the mock window reaches here mocker.patch("filelock._api.os.close", side_effect=close) return attempts @NEEDS_FCNTL # pragma: needs fcntl def test_close_error_default_suppressed_on_unix(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) # default policy lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) lock.release() # Unix default drops a FUSE/Docker EIO assert not lock.is_locked @_WINDOWS_ONLY # pragma: win32 cover def test_close_error_default_raises_on_windows(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a")) # default policy lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) with pytest.raises(OSError, match="close failed"): lock.release() assert not lock.is_locked def test_close_error_suppress(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="suppress") lock.acquire() _fail_close_of(mocker, lock, OSError(EIO, "close failed")) lock.release() assert not lock.is_locked @pytest.mark.parametrize("errno", [EINTR, EIO, ENOSPC]) def test_close_error_raise_is_exact_and_committed(tmp_path: Path, mocker: MockerFixture, errno: int) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise") lock.acquire() injected = OSError(errno, "close failed") attempts = _fail_close_of(mocker, lock, injected) with pytest.raises(OSError, match="close failed") as info: lock.release() assert info.value is injected # the original error, no wrapper assert len(attempts) == 1 # os.close is never retried, even after EINTR assert not lock.is_locked # the unlock committed even though close failed assert lock.lock_counter == 0 @NEEDS_FCNTL # pragma: needs fcntl def test_close_not_reached_when_unlock_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise") lock.acquire() fd = lock._context.lock_file_fd mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) real_close = os.close closed: list[int] = [] mocker.patch("filelock._api.os.close", side_effect=lambda target: closed.append(target) or real_close(target)) with pytest.raises(OSError, match="unlock failed"): lock.release() assert lock.is_locked # the kernel unlock failed, so the lock is still held assert fd not in closed # close is not reached while the lock is still held lock.release() # retry: unlock succeeds and close runs assert not lock.is_locked assert fd in closed def test_dual_body_and_close_failure_grouped(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), close_error_policy="raise", context_error_policy="group") lock.acquire() _fail_close_of(mocker, lock, OSError("close failed")) body = ValueError("body failed") with pytest.raises(ExceptionGroup) as info: lock._release_in_context(body) # what __exit__ runs; the close failure joins the body failure leaf_body, close = info.value.exceptions assert isinstance(leaf_body, ValueError) assert isinstance(close, OSError) def test_invalid_close_error_policy_rejected(tmp_path: Path) -> None: with pytest.raises(ValueError, match="close_error_policy must be"): FileLock(str(tmp_path / "a"), close_error_policy="explode") # ty: ignore[invalid-argument-type] def test_singleton_rejects_different_close_policy(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, close_error_policy="raise") try: with pytest.raises(ValueError, match="close_error_policy"): FileLock(path, is_singleton=True, close_error_policy="suppress") finally: first.release(force=True) def test_singleton_shares_across_equivalent_spellings(tmp_path: Path) -> None: (tmp_path / "sub").mkdir() absolute = FileLock(str(tmp_path / "a"), is_singleton=True) dot = FileLock(str(tmp_path) + "/./a", is_singleton=True) dotdot = FileLock(str(tmp_path / "sub") + "/../a", is_singleton=True) try: assert absolute is dot is dotdot # one instance for equivalent spellings of one path finally: absolute.release(force=True) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_singleton_shares_through_symlinked_parent(tmp_path: Path) -> None: real = tmp_path / "real" real.mkdir() (tmp_path / "link").symlink_to(real) via_real = FileLock(str(real / "a"), is_singleton=True) via_link = FileLock(str(tmp_path / "link" / "a"), is_singleton=True) try: assert via_real is via_link # a symlinked parent directory resolves to the same key finally: via_real.release(force=True) @NEEDS_SYMLINK # pragma: needs symlink def test_final_symlink_stays_a_distinct_key(tmp_path: Path) -> None: target = tmp_path / "target" target.write_text("") (tmp_path / "link").symlink_to(target) on_link = FileLock(str(tmp_path / "link"), is_singleton=True) on_target = FileLock(str(target), is_singleton=True) try: assert on_link is not on_target # the final component is not resolved, so the symlink is its own key finally: on_link.release(force=True) on_target.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl @_NEEDS_O_NOFOLLOW # pragma: needs o-nofollow def test_final_symlink_backend_refuses_to_lock(tmp_path: Path) -> None: (tmp_path / "target").write_text("") (tmp_path / "link").symlink_to(tmp_path / "target") # Keeping the final symlink a distinct key is safe because the backend still refuses to lock through it. O_NOFOLLOW # reports the refusal as ELOOP on Linux and macOS, and as EFTYPE ("inappropriate file type") on NetBSD. with pytest.raises(OSError, match=r"Too many levels of symbolic links|symbolic link|Inappropriate file type"): FileLock(str(tmp_path / "link")).acquire() def test_separate_lock_classes_keep_separate_registries(tmp_path: Path) -> None: path = str(tmp_path / "a") native = FileLock(path, is_singleton=True) soft = SoftFileLock(path, is_singleton=True) try: assert native is not soft # each class caches its own singletons finally: native.release(force=True) soft.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_to_soft_disabled_raises_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), fallback_to_soft=False) with pytest.raises(OSError, match="no flock") as info: lock.acquire() assert info.value.errno == ENOSYS # the original error, not a soft-lock timeout assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # the class is not swapped to SoftFileLock assert lock.lock_counter == 0 # the failed acquire left no holder count @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_to_soft_default_switches_to_soft(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a")) # default fallback_to_soft=True with pytest.warns(UserWarning, match="falling back to SoftFileLock"): lock.acquire() try: assert lock.is_locked assert isinstance(lock, SoftFileLock) # switched to existence-lock semantics finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_fallback_disabled_recursive_reraises(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), fallback_to_soft=False) for _ in range(2): # a repeat acquire keeps failing the same way, never a soft downgrade with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_singleton_rejects_different_fallback_to_soft(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, fallback_to_soft=True) try: with pytest.raises(ValueError, match="fallback_to_soft"): FileLock(path, is_singleton=True, fallback_to_soft=False) finally: first.release(force=True) def test_lock_descriptor_roundtrip(tmp_path: Path) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False) is True os.write(fd, b"held") # the descriptor stays usable while locked unlock_descriptor(fd) os.lseek(fd, 0, os.SEEK_SET) assert os.read(fd, 4) == b"held" # and after unlock finally: os.close(fd) def test_lock_descriptor_nonblocking_contention(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) contender = os.open(path, os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(holder, blocking=False) is True assert lock_descriptor(contender, blocking=False) is False # a second descriptor sees the lock unlock_descriptor(holder) assert lock_descriptor(contender, blocking=False) is True # free once the holder releases unlock_descriptor(contender) finally: os.close(holder) os.close(contender) def test_lock_descriptor_invalid_fd_raises(tmp_path: Path) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) os.close(fd) # a closed descriptor is invalid; the native lock must raise, not silently succeed or contend with pytest.raises(OSError, match=r"Bad file descriptor|not open|invalid"): lock_descriptor(fd, blocking=False) @pytest.mark.parametrize( "poll_interval", _INVALID_DESCRIPTOR_POLL_INTERVALS, ) def test_lock_descriptor_rejects_invalid_blocking_poll_interval(poll_interval: float) -> None: with pytest.raises(ValueError, match="poll_interval must be finite and greater than 0"): lock_descriptor(-1, poll_interval=poll_interval) @pytest.mark.parametrize( "poll_interval", _INVALID_DESCRIPTOR_POLL_INTERVALS, ) def test_lock_descriptor_nonblocking_ignores_poll_interval(tmp_path: Path, poll_interval: float) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False, poll_interval=poll_interval) is True unlock_descriptor(fd) finally: os.close(fd) @pytest.mark.parametrize("direction", ["filelock_first", "descriptor_first"]) def test_filelock_and_descriptor_contend(tmp_path: Path, direction: str) -> None: path = str(tmp_path / "a") lock = FileLock(path) fd = os.open(path, os.O_RDWR | os.O_CREAT) try: if direction == "filelock_first": lock.acquire() assert lock_descriptor(fd, blocking=False) is False # the path lock blocks the descriptor lock lock.release() assert lock_descriptor(fd, blocking=False) is True unlock_descriptor(fd) else: assert lock_descriptor(fd, blocking=False) is True with pytest.raises(Timeout): lock.acquire(timeout=0.2) # the descriptor lock blocks the path lock unlock_descriptor(fd) lock.acquire() lock.release() finally: os.close(fd) @NEEDS_FCNTL # pragma: needs fcntl def test_lock_descriptor_touches_no_paths(tmp_path: Path) -> None: path = tmp_path / "a" fd = os.open(str(path), os.O_RDWR | os.O_CREAT) try: os.write(fd, b"payload") before = os.fstat(fd) assert lock_descriptor(fd, blocking=False) is True unlock_descriptor(fd) after = os.fstat(fd) # The adapter works purely on the descriptor: our fd stays open on the same inode with its size and mode # intact, and the file keeps its contents. That rules out any open, close, unlink, truncate or chmod on our # behalf without spying on hot global os functions, which unrelated tempfile cleanup would race and pollute. assert (after.st_ino, after.st_dev, after.st_size, after.st_mode) == ( before.st_ino, before.st_dev, before.st_size, before.st_mode, ) assert path.read_bytes() == b"payload" finally: os.close(fd) @NEEDS_FCNTL # pragma: needs fcntl def test_unlock_descriptor_failure_allows_retry(tmp_path: Path, mocker: MockerFixture) -> None: fd = os.open(str(tmp_path / "a"), os.O_RDWR | os.O_CREAT) try: assert lock_descriptor(fd, blocking=False) is True mocker.patch("filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(OSError, match="unlock failed"): unlock_descriptor(fd) unlock_descriptor(fd) # the same descriptor can retry finally: os.close(fd) def test_lock_descriptor_blocking_retries_until_free(tmp_path: Path, mocker: MockerFixture) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) fd = os.open(path, os.O_RDWR | os.O_CREAT) assert lock_descriptor(holder, blocking=False) is True # Drive the real blocking loop: the first attempt sees contention, the mocked sleep frees the holder, and the # second attempt wins. Only the clock is mocked, so a single sleep call proves exactly one retry happened. sleep = mocker.patch("filelock._descriptor.time.sleep", side_effect=lambda _: unlock_descriptor(holder)) try: assert lock_descriptor(fd, blocking=True, poll_interval=0.01) is True sleep.assert_called_once_with(0.01) unlock_descriptor(fd) finally: os.close(holder) os.close(fd) #: NetBSD has no CLOCK_THREAD_CPUTIME_ID, so time.thread_time is absent; process CPU time proves "not spinning" too. _CPU_TIME: Final[Callable[[], float]] = getattr(time, "thread_time", time.process_time) def test_lock_descriptor_blocking_wait_does_not_spin(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = os.open(path, os.O_RDWR | os.O_CREAT) contender = os.open(path, os.O_RDWR | os.O_CREAT) assert lock_descriptor(holder, blocking=False) is True started = threading.Event() def acquire() -> float: started.set() before = _CPU_TIME() lock_descriptor(contender, poll_interval=0.01) return _CPU_TIME() - before try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(acquire) try: assert started.wait(timeout=1) time.sleep(0.2) finally: unlock_descriptor(holder) assert future.result(timeout=2) < 0.05 unlock_descriptor(contender) finally: os.close(holder) os.close(contender) def test_preserve_lock_file_defaults_off(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a")).preserve_lock_file is False def test_preserve_lock_file_property_reflects_argument(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a"), preserve_lock_file=True).preserve_lock_file is True def test_preserve_lock_file_rejected_by_soft_lock(tmp_path: Path) -> None: with pytest.raises(ValueError, match="preserve_lock_file"): SoftFileLock(str(tmp_path / "a"), preserve_lock_file=True) def test_singleton_rejects_different_preserve_lock_file(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, preserve_lock_file=False) try: with pytest.raises(ValueError, match="preserve_lock_file"): FileLock(path, is_singleton=True, preserve_lock_file=True) finally: first.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_preserve_lock_file_unix_keeps_pathname(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() assert path.exists() # Unix leaves the native pathname in place assert type(lock).__name__ == "UnixFileLock" @NEEDS_FCNTL # pragma: needs fcntl def test_preserve_lock_file_fails_closed_on_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) lock = FileLock(str(tmp_path / "a"), preserve_lock_file=True) # fallback_to_soft still defaults to True with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # preserve overrides the soft fallback that would unlink to release @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_default_removes_file(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path)) lock.acquire() lock.release() assert not path.exists() # the default Windows cleanup unlinks the lock file @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_keeps_file(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() assert path.exists() # preserve_lock_file skips the post-release unlink @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_reacquires_same_identity(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.release() kept = path.stat() lock.acquire() try: current = path.stat() assert (current.st_dev, current.st_ino) == (kept.st_dev, kept.st_ino) # same file across acquisitions finally: lock.release() @_WINDOWS_ONLY # pragma: win32 cover def test_preserve_lock_file_windows_kept_after_forced_release(tmp_path: Path) -> None: path = tmp_path / "a" lock = FileLock(str(path), preserve_lock_file=True) lock.acquire() lock.acquire() # reentrant second level lock.release(force=True) # drop every level and run the release path once assert not lock.is_locked assert path.exists() # preserved even through a forced release def _noop_on_acquired(_fd: int) -> None: pass def _failing_on_acquired(_fd: int) -> None: msg = "hook failed" raise RuntimeError(msg) def test_on_acquired_defaults_none(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a")).on_acquired is None def test_on_acquired_property_reflects_argument(tmp_path: Path) -> None: assert FileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired).on_acquired is _noop_on_acquired def test_on_acquired_noop_hook_allows_acquire(tmp_path: Path) -> None: lock = FileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired) lock.acquire() # the no-op hook runs during acquisition try: assert lock.is_locked finally: lock.release() def test_on_acquired_runs_before_acquire_returns(tmp_path: Path) -> None: fd_while_held = -1 locked_during_hook = False def hook(fd: int) -> None: nonlocal fd_while_held, locked_during_hook locked_during_hook = lock.is_locked fd_while_held = fd lock = FileLock(str(tmp_path / "a"), on_acquired=hook) lock.acquire() try: assert locked_during_hook # the hook ran while the lock was held ... assert fd_while_held >= 0 # ... and got a real descriptor, all before acquire() returned finally: lock.release() def test_on_acquired_fires_once_per_physical_acquire(tmp_path: Path) -> None: calls: list[int] = [] lock = FileLock(str(tmp_path / "a"), on_acquired=calls.append) lock.acquire() lock.acquire() # recursive: the poll loop skips _acquire, so the hook does not run again try: assert len(calls) == 1 finally: lock.release() lock.release() def test_on_acquired_not_called_for_contender(tmp_path: Path) -> None: path = str(tmp_path / "a") holder = FileLock(path) holder.acquire() calls: list[int] = [] contender = FileLock(path, on_acquired=calls.append) try: with pytest.raises(Timeout): contender.acquire(timeout=0.1) assert calls == [] # a contender that never acquires never runs the hook finally: holder.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_runs_after_truncation_and_mode(tmp_path: Path) -> None: path = tmp_path / "a" path.write_bytes(b"stale content") seen: dict[str, int] = {} def hook(fd: int) -> None: stat_result = os.fstat(fd) seen["size"] = stat_result.st_size seen["mode"] = S_IMODE(stat_result.st_mode) lock = FileLock(str(path), mode=0o600, on_acquired=hook) lock.acquire() try: assert seen == {"size": 0, "mode": 0o600} # backend truncation and mode setup finished before the hook finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_writes_survive_contention(tmp_path: Path) -> None: path = str(tmp_path / "a") def write_metadata(fd: int) -> None: os.write(fd, b"holder-metadata") writer = FileLock(path, on_acquired=write_metadata) writer.acquire() try: contender = FileLock(path) with pytest.raises(Timeout): contender.acquire(timeout=0.1) # a losing contender must not truncate the holder's file assert Path(path).read_bytes() == b"holder-metadata" finally: writer.release() def test_on_acquired_failure_releases_lock(tmp_path: Path) -> None: path = str(tmp_path / "a") lock = FileLock(path, on_acquired=_failing_on_acquired) with pytest.raises(RuntimeError, match="hook failed"): lock.acquire() assert not lock.is_locked assert lock.lock_counter == 0 other = FileLock(path) other.acquire() # the native lock was released, so a fresh acquire succeeds at once other.release() @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_and_release_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: path = str(tmp_path / "a") lock = FileLock(path, on_acquired=_failing_on_acquired) mocker.patch("filelock._unix._unlock_fd", side_effect=[OSError(EIO, "unlock failed"), None]) with pytest.raises(BaseExceptionGroup) as info: lock.acquire() assert {type(error) for error in info.value.exceptions} == {RuntimeError, OSError} assert lock.is_locked # the OS unlock failed, so the lock stays held for a retry assert lock.lock_counter == 1 lock.release() # the second unlock succeeds def test_on_acquired_rollback_group_detaches_release_context( tmp_path: Path, close_failure: tuple[Callable[[int], None], OSError, RuntimeError] ) -> None: capture, release_error, release_cause = close_failure callback_error = RuntimeError("hook failed") callback_cause = LookupError("hook cause") def fail(fd: int) -> None: capture(fd) raise callback_error from callback_cause lock = FileLock(str(tmp_path / "a"), close_error_policy="raise", on_acquired=fail) with pytest.raises(ExceptionGroup) as info: lock.acquire() assert ( info.value.exceptions, callback_error.__context__, release_error.__context__, callback_error.__cause__, release_error.__cause__, callback_error.__traceback__ is not None, release_error.__traceback__ is not None, ) == ((callback_error, release_error), None, None, callback_cause, release_cause, True, True) def test_grouped_errors_keep_a_context_that_is_a_distinct_group() -> None: # A genuinely distinct group-valued __context__ must survive the detach rather than read as a duplicate. context = ExceptionGroup("context group", [ValueError("a")]) member = ExceptionGroup("member group", [KeyError("b"), KeyError("c")]) member.__context__ = context with pytest.raises(BaseExceptionGroup) as info: _raise_grouped_errors("grouped", member, RuntimeError("other")) assert member.__context__ is context assert member in info.value.exceptions def test_grouped_errors_detach_a_context_already_nested_in_a_sibling() -> None: # A context already present in a sibling's tree is dropped so the group never carries the same leaf twice. shared = ValueError("shared leaf") sibling = ExceptionGroup("sibling", [shared]) member = RuntimeError("boom") member.__context__ = shared with pytest.raises(BaseExceptionGroup) as info: _raise_grouped_errors("grouped", sibling, member) assert member.__context__ is None assert set(info.value.exceptions) == {sibling, member} def test_rollback_failed_acquire_groups_registration_and_release_failures( tmp_path: Path, mocker: MockerFixture ) -> None: # An acquire that raised while still holding the lock re-registers the descriptor and releases; when both that # re-registration and the release then fail, every failure survives as a sibling of the group raised. lock = SoftFileLock(str(tmp_path / "a")) lock.acquire() registration_error = OSError("registration failed") release_error = OSError("release failed") mocker.patch.object(lock, "_register_context_descriptor", side_effect=registration_error) mocker.patch.object(lock, "_release_with_fork_tracking", side_effect=release_error) acquisition_error = RuntimeError("acquire failed") try: with pytest.raises(BaseExceptionGroup) as info: lock._rollback_failed_acquire(acquisition_error) assert set(info.value.exceptions) == {acquisition_error, registration_error, release_error} finally: mocker.stopall() lock.release(force=True) @NEEDS_FCNTL # pragma: needs fcntl def test_on_acquired_fails_closed_on_enosys(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) calls: list[int] = [] lock = FileLock(str(tmp_path / "a"), on_acquired=calls.append) with pytest.raises(OSError, match="no flock"): lock.acquire() assert not lock.is_locked assert type(lock).__name__ == "UnixFileLock" # a soft lock cannot run the hook, so no fallback assert calls == [] def test_on_acquired_rejected_by_soft_lock(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired"): SoftFileLock(str(tmp_path / "a"), on_acquired=_noop_on_acquired) def test_singleton_shares_same_on_acquired(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) try: assert FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) is first finally: first.release(force=True) def test_singleton_rejects_different_on_acquired(tmp_path: Path) -> None: path = str(tmp_path / "a") first = FileLock(path, is_singleton=True, on_acquired=_noop_on_acquired) try: with pytest.raises(ValueError, match="on_acquired"): FileLock(path, is_singleton=True, on_acquired=_failing_on_acquired) finally: first.release(force=True) def test_import_succeeds_without_os_link() -> None: # Termux/Android CPython has no os.link, and the strict backend touched it at import, so `from filelock import # FileLock` crashed with AttributeError there (discussion #677). Importing in a fresh interpreter with os.link # removed must still succeed. script = ( "import os\n" "if hasattr(os, 'link'):\n" " del os.link\n" "from filelock import FileLock, StrictSoftFileLock\n" "print('ok')\n" ) run = [sys.executable, "-c", script] result = subprocess.run(run, capture_output=True, text=True, check=False) assert (result.returncode, result.stdout.strip()) == (0, "ok"), result.stderr def test_strict_lock_reports_unsupported_without_os_link(tmp_path: Path, mocker: MockerFixture) -> None: # With os.link absent the strict backend cannot publish a hard-link claim, so acquire must raise the same # unsupported-filesystem error a hard-link-refusing filesystem gets, not a bare AttributeError. mocker.patch("filelock._strict._HAS_LINK", False) lock = StrictSoftFileLock(str(tmp_path / "resource.lock")) with pytest.raises(SoftFileLockProtocolError, match="hard-link publication"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_soft_release_groups_close_and_cleanup_failures(tmp_path: Path, mocker: MockerFixture) -> None: lock = SoftFileLock(str(tmp_path / "grouped.lock")) lock.acquire() mocker.patch.object(lock, "_close_released_fd", side_effect=OSError("close boom")) mocker.patch.object(lock, "_unlink_held_marker", side_effect=OSError("cleanup boom")) with pytest.raises(BaseExceptionGroup, match="both failed") as excinfo: lock._release() messages = {str(exc) for exc in excinfo.value.exceptions} assert any("close boom" in message for message in messages) assert any("cleanup boom" in message for message in messages) def test_soft_windows_unlink_stops_on_non_permission_error(tmp_path: Path, mocker: MockerFixture) -> None: path = tmp_path / "windows.lock" path.write_bytes(b"x") lock = SoftFileLock(str(path)) st = os.lstat(path) mocker.patch.object(Path, "unlink", side_effect=OSError(ENOSYS, "boom")) lock._windows_unlink_if_ours((st.st_dev, st.st_ino)) assert path.exists() @pytest.mark.timeout(5) def test_append_exception_context_terminates_on_a_cycle() -> None: first = RuntimeError("first") second = RuntimeError("second") first.__context__ = second second.__context__ = first # a cycle that never reaches the context below context = ValueError("context") _append_exception_context(first, context) # The walk hit the already-seen tail and stopped instead of looping forever or threading context into the ring. assert first.__context__ is second assert second.__context__ is first def test_undo_acquire_keeps_registry_entry_while_still_held(tmp_path: Path, mocker: MockerFixture) -> None: lock = FileLock(str(tmp_path / "a"), is_singleton=False) lock.acquire() key = lock._context.lock_file_key assert _registry.held.get(key) == id(lock) # A reentrant acquire that fails while the lock reports unheld routes through _undo_acquire with the counter # still above zero, so the registry entry the outer hold owns must survive. held_fd = lock._context.lock_file_fd lock._context.lock_file_fd = None # is_locked now reports False mocker.patch.object(lock, "_poll_until_acquired", side_effect=Timeout(lock.lock_file)) with pytest.raises(Timeout): lock.acquire(timeout=0) assert lock._context.lock_counter == 1 # decremented from 2, the deeper hold remains assert _registry.held.get(key) == id(lock) # entry preserved because the counter did not reach zero lock._context.lock_file_fd = held_fd lock.release() def test_soft_marker_omits_start_line_without_a_start_token(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._soft.process_start_token", return_value=None) lock = SoftFileLock(str(tmp_path / "a")) with lock: lines = Path(lock.lock_file).read_text(encoding="utf-8").splitlines() assert len(lines) == 2 # pid and hostname only; the optional start-token line is omitted assert lines[0] == str(os.getpid()) def test_soft_is_lock_held_by_us_false_for_unparseable_marker(tmp_path: Path) -> None: lock_path = tmp_path / "a" lock_path.write_text("garbage-not-a-marker", encoding="utf-8") lock = SoftFileLock(str(lock_path)) assert lock.is_lock_held_by_us is False # an unreadable holder is nobody, so certainly not us @NEEDS_FCNTL # pragma: needs fcntl def test_unix_acquire_without_o_nofollow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delattr(os, "O_NOFOLLOW", raising=False) lock = FileLock(str(tmp_path / "a"), is_singleton=False) lock.acquire() try: assert lock.is_locked # acquisition still works on a platform without O_NOFOLLOW finally: lock.release() @NEEDS_FCNTL # pragma: needs fcntl def test_unix_soft_fallback_keeps_a_replaced_file(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "a" lock = FileLock(str(lock_path), is_singleton=False) target = lock.lock_file mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "no flock")) real_lstat = os.lstat def lstat_swapped_identity(path: str) -> os.stat_result: st = real_lstat(path) if str(path) != target: return st fields = list(st) fields[1] += 1 # bump st_ino so the on-disk identity no longer matches the opened fd return os.stat_result(fields) mocker.patch("filelock._unix.os.lstat", side_effect=lstat_swapped_identity) with pytest.raises(Timeout), pytest.warns(UserWarning, match="falling back to SoftFileLock"): lock.acquire(timeout=0.2) assert isinstance(lock, SoftFileLock) # fell back after refusing to unlink the mismatched file assert lock_path.exists() # the replacement file was left in place filelock-3.32.2/tests/test_fork_backends.py0000644000000000000000000004231113615410400015660 0ustar00from __future__ import annotations import asyncio import os import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreter controls child callback registration order import sys from errno import EBADF from typing import TYPE_CHECKING, NoReturn, cast import pytest from filelock import BaseAsyncFileLock, BaseFileLock from tests.capability_marks import NEEDS_FORK, NEEDS_GENERATOR_EXCEPTION_CONTEXT from tests.fork_helpers import exit_child, fork_process if sys.version_info >= (3, 11): from builtins import BaseExceptionGroup # pragma: >=3.11 cover else: # pragma: <3.11 cover from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture class _DescriptorLock(BaseFileLock): acquire_error: BaseException | None = None descriptor: int | None = None fail_release: bool = False def _acquire(self) -> None: # pragma: needs fork self.descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) if self.acquire_error is not None: raise self.acquire_error def _release(self) -> None: # pragma: needs fork if self.fail_release: msg = "unlock failed" raise RuntimeError(msg) os.close(cast("int", self._context.lock_file_fd)) self._context.lock_file_fd = None class _CoroutineDescriptorLock(BaseAsyncFileLock): acquire_error: BaseException | None = None acquire_error_before_descriptor: BaseException | None = None descriptor: int | None = None release_error: BaseException | None = None release_error_before_close: BaseException | None = None async def _acquire(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported if self.acquire_error_before_descriptor is not None: raise self.acquire_error_before_descriptor self.descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) if self.acquire_error is not None: # pragma: needs fork raise self.acquire_error async def _release(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported if self.release_error_before_close is not None: # pragma: needs fork raise self.release_error_before_close os.close(cast("int", self._context.lock_file_fd)) self._context.lock_file_fd = None if self.release_error is not None: raise self.release_error @NEEDS_FORK # pragma: needs fork def test_third_party_descriptor_is_closed_in_child(tmp_path: Path) -> None: descriptors: list[int] = [] lock = _DescriptorLock(str(tmp_path / "third-party.lock"), is_singleton=False, on_acquired=descriptors.append) lock.acquire() child_pid = _fork_descriptor_probe(descriptors[0], os.fstat) _, status = os.waitpid(child_pid, 0) lock.release() assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork def test_unlock_failure_keeps_descriptor_registered(tmp_path: Path) -> None: descriptors: list[int] = [] lock = _DescriptorLock(str(tmp_path / "retry.lock"), is_singleton=False, on_acquired=descriptors.append) lock.acquire() lock.fail_release = True with pytest.raises(RuntimeError, match="unlock failed"): lock.release() child_pid = _fork_descriptor_probe(descriptors[0], os.fstat) _, status = os.waitpid(child_pid, 0) lock.fail_release = False lock.release() assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork def test_acquisition_failure_keeps_descriptor_registered_until_rollback(tmp_path: Path) -> None: lock = _DescriptorLock(str(tmp_path / "partial.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.fail_release = True with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) child_pid = _fork_descriptor_probe(cast("int", lock.descriptor), os.fstat) _, status = os.waitpid(child_pid, 0) lock.acquire_error = None lock.fail_release = False lock.release() assert ( _error_details(info.value), os.waitstatus_to_exitcode(status), ) == ( [(RuntimeError, "acquire failed"), (RuntimeError, "unlock failed")], 0, ) @pytest.mark.parametrize( ("fail_release", "expected"), [ pytest.param( False, [(RuntimeError, "acquire failed"), (OSError, "registration failed")], id="rollback-succeeds", ), pytest.param( True, [ (RuntimeError, "acquire failed"), (OSError, "registration failed"), (RuntimeError, "unlock failed"), ], id="rollback-fails", ), ], ) @NEEDS_FORK # pragma: needs fork def test_acquisition_and_registration_errors_are_grouped( tmp_path: Path, mocker: MockerFixture, *, fail_release: bool, expected: list[tuple[type[BaseException], str]], ) -> None: lock = _DescriptorLock(str(tmp_path / "registration.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.fail_release = fail_release real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = os.waitpid(child_pid, 0) lock.acquire_error = None lock.fail_release = False lock.release(force=True) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == (expected, 0) @NEEDS_FORK # pragma: needs fork def test_registration_failure_tracks_descriptor_when_rollback_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock = _DescriptorLock(str(tmp_path / "group.lock"), is_singleton=False) lock.fail_release = True real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = os.waitpid(child_pid, 0) lock.fail_release = False lock.release() with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(OSError, "registration failed"), (RuntimeError, "unlock failed")], 0, ) @NEEDS_FORK # pragma: needs fork def test_unverified_descriptor_does_not_close_reused_child_fd(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys if sys.version_info >= (3, 11): from builtins import BaseExceptionGroup # pragma: >=3.11 cover else: from exceptiongroup import BaseExceptionGroup descriptor = -1 replacement_descriptor = -1 real_fstat = os.fstat def replace_descriptor_in_child() -> None: global replacement_descriptor os.close(descriptor) replacement_descriptor = os.open(sys.argv[2], os.O_CREAT | os.O_RDWR, 0o600) os.register_at_fork(after_in_child=replace_descriptor_in_child) from filelock import BaseFileLock class FailingRollbackLock(BaseFileLock): fail_release = True def _acquire(self) -> None: global descriptor descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: if self.fail_release: raise RuntimeError("rollback failed") os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None def fail_descriptor_probe(fd: int) -> os.stat_result: if fd == descriptor: raise OSError("registration failed") return real_fstat(fd) os.fstat = fail_descriptor_probe lock = FailingRollbackLock(sys.argv[1], is_singleton=False) try: lock.acquire(timeout=0) except BaseExceptionGroup: pass else: raise SystemExit(2) child_pid = os.fork() if child_pid == 0: try: real_fstat(replacement_descriptor) except OSError: os._exit(1) os._exit(0 if replacement_descriptor == descriptor else 2) _, status = os.waitpid(child_pid, 0) os.fstat = real_fstat lock.fail_release = False lock.release() raise SystemExit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [ sys.executable, "-c", script, str(tmp_path / "lock"), str(tmp_path / "replacement"), ], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork def test_control_flow_registration_error_rolls_back_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: descriptors: list[int] = [] class StopAcquire(BaseException): pass class CapturingLock(_DescriptorLock): def _acquire(self) -> None: super()._acquire() descriptors.append(cast("int", self._context.lock_file_fd)) real_fstat = os.fstat def interrupt_registration(fd: int) -> os.stat_result: assert fd == descriptors[0] raise StopAcquire mocker.patch("os.fstat", side_effect=interrupt_registration) with pytest.raises(StopAcquire): CapturingLock(str(tmp_path / "interrupt.lock"), is_singleton=False).acquire(timeout=0) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptors[0]) @pytest.mark.asyncio @NEEDS_FORK # pragma: needs fork async def test_coroutine_registration_error_rolls_back_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: descriptors: list[int] = [] real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: descriptors.append(fd) msg = "registration failed" raise OSError(msg) mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(OSError, match="registration failed"): await _CoroutineDescriptorLock(str(tmp_path / "coroutine.lock"), is_singleton=False).acquire(timeout=0) with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptors[0]) @pytest.mark.asyncio async def test_coroutine_acquisition_failure_before_descriptor_propagates(tmp_path: Path) -> None: acquisition_error = RuntimeError("acquire failed") lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-empty.lock"), is_singleton=False) lock.acquire_error_before_descriptor = acquisition_error with pytest.raises(RuntimeError, match="acquire failed") as info: await lock.acquire(timeout=0) assert (info.value is acquisition_error, lock.is_locked) == (True, False) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_acquisition_failure_keeps_descriptor_registered_until_rollback(tmp_path: Path) -> None: lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-partial.lock"), is_singleton=False, context_error_policy="group" ) lock.acquire_error = RuntimeError("acquire failed") lock.release_error = RuntimeError("release failed") with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) child_pid = _fork_descriptor_probe(descriptor, os.fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.acquire_error = None lock.release_error = None await lock.release() assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(RuntimeError, "acquire failed"), (RuntimeError, "release failed")], 0, ) @pytest.mark.parametrize( ("release_error", "expected"), [ pytest.param( None, [(RuntimeError, "acquire failed"), (OSError, "registration failed")], id="rollback-succeeds", ), pytest.param( RuntimeError("release failed"), [ (RuntimeError, "acquire failed"), (OSError, "registration failed"), (RuntimeError, "release failed"), ], id="rollback-fails", ), ], ) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_acquisition_and_registration_errors_are_grouped( tmp_path: Path, mocker: MockerFixture, release_error: BaseException | None, expected: list[tuple[type[BaseException], str]], ) -> None: lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-registration.lock"), is_singleton=False) lock.acquire_error = RuntimeError("acquire failed") lock.release_error = release_error real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) # Probe the closed descriptor before the fork and waitpid below, whose own opens could reclaim its freed number. with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.acquire_error = None lock.release_error = None await lock.release(force=True) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == (expected, 0) @NEEDS_FORK # pragma: needs fork @pytest.mark.asyncio async def test_coroutine_registration_failure_tracks_descriptor_when_rollback_fails( tmp_path: Path, mocker: MockerFixture ) -> None: lock = _CoroutineDescriptorLock(str(tmp_path / "coroutine-group.lock"), is_singleton=False) lock.release_error_before_close = RuntimeError("rollback failed") real_fstat = os.fstat def fail_registration(fd: int) -> os.stat_result: assert fd == lock.descriptor msg = "registration failed" raise OSError(msg) fstat_mock = mocker.patch("os.fstat", side_effect=fail_registration) with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) descriptor = cast("int", lock.descriptor) mocker.stop(fstat_mock) child_pid = _fork_descriptor_probe(descriptor, real_fstat) _, status = await asyncio.to_thread(os.waitpid, child_pid, 0) lock.release_error_before_close = None await lock.release() with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(descriptor) assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == ( [(OSError, "registration failed"), (RuntimeError, "rollback failed")], 0, ) @NEEDS_GENERATOR_EXCEPTION_CONTEXT @pytest.mark.asyncio async def test_coroutine_on_acquired_error_preserves_context(tmp_path: Path) -> None: callback_error = RuntimeError("hook failed") prior_context = LookupError("prior callback failure") callback_error.__context__ = prior_context def fail_callback(_fd: int) -> None: raise callback_error lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-hook-context.lock"), is_singleton=False, on_acquired=fail_callback ) with pytest.raises(RuntimeError, match="hook failed") as info: await lock.acquire(timeout=0) assert (info.value is callback_error, callback_error.__context__, lock.is_locked) == (True, prior_context, False) @pytest.mark.asyncio async def test_coroutine_on_acquired_and_rollback_errors_are_grouped(tmp_path: Path) -> None: callback_error = RuntimeError("hook failed") def fail_callback(_fd: int) -> None: raise callback_error lock = _CoroutineDescriptorLock( str(tmp_path / "coroutine-hook.lock"), is_singleton=False, on_acquired=fail_callback ) lock.release_error = RuntimeError("release failed") with pytest.raises(BaseExceptionGroup) as info: await lock.acquire(timeout=0) assert _error_details(info.value) == [ (RuntimeError, "hook failed"), (RuntimeError, "release failed"), ] def _error_details(group: BaseExceptionGroup) -> list[tuple[type[BaseException], str]]: return [(type(error), str(error)) for error in group.exceptions] def _fork_descriptor_probe( # pragma: needs fork descriptor: int, stat_descriptor: Callable[[int], os.stat_result] ) -> int: def probe_child() -> NoReturn: with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): stat_descriptor(descriptor) exit_child(0) return fork_process(probe_child) filelock-3.32.2/tests/test_fork_coordination.py0000644000000000000000000010272113615410400016600 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreters control at-fork registration order import sys import threading from typing import TYPE_CHECKING, Final, Literal import pytest from filelock import BaseFileLock, Timeout, has_fcntl from tests.capability_marks import NEEDS_FORK, NEEDS_FORK1 from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from pathlib import Path _FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" ) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_callbacks_registered_before_filelock_can_use_locks(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys callback_lock = None child_ok = False parent_callback_ran = False def before() -> None: global callback_lock callback_lock = FileLock(sys.argv[2], is_singleton=True) callback_lock.acquire() def parent() -> None: global parent_callback_ran if callback_lock is None or not callback_lock.is_locked: return callback_lock.release() parent_callback_ran = True def child() -> None: global child_ok if callback_lock is None: return child_singleton = FileLock(sys.argv[1], is_singleton=True) child_ok = ( not callback_lock.is_locked and callback_lock.lock_counter == 0 and child_singleton is not parent_singleton ) os.register_at_fork(before=before, after_in_parent=parent, after_in_child=child) from filelock import FileLock parent_singleton = FileLock(sys.argv[1], is_singleton=True) child_pid = os.fork() if child_pid == 0: os._exit(0 if child_ok else 1) _, status = os.waitpid(child_pid, 0) if ( os.waitstatus_to_exitcode(status) != 0 or not parent_callback_ran or callback_lock is None or callback_lock.is_locked ): sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock"), str(tmp_path / "callback.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "audit_mode", [pytest.param("installed", id="audit-installed"), pytest.param("rejected", id="audit-rejected")], ) def test_concurrent_fork_after_first_snapshot_does_not_deadlock( tmp_path: Path, audit_mode: Literal["installed", "rejected"] ) -> None: script = """ from __future__ import annotations import os import sys import threading from queue import Queue entered = threading.Event() release = threading.Event() statuses: Queue[int] = Queue() def block_before_fork() -> None: if threading.current_thread().name == "fork-owner": entered.set() if not release.wait(timeout=5): raise RuntimeError("fork owner was not released") os.register_at_fork(before=block_before_fork) if sys.argv[2] == "rejected": # Audit arguments mix interpreter-owned types; object is their only accurate common type. def reject_add_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_add_hook) from filelock import FileLock FileLock(sys.argv[1], is_singleton=False) def fork_once() -> None: child_pid = os.fork() if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) statuses.put(os.waitstatus_to_exitcode(status)) owner = threading.Thread(target=fork_once, name="fork-owner") owner.start() if not entered.wait(timeout=5): sys.exit(1) try: child_pid = os.fork() except RuntimeError as exception: second_status = 0 if "unsafe while filelock is changing descriptor ownership" in str(exception) else 1 else: if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) second_status = os.waitstatus_to_exitcode(status) release.set() owner.join(timeout=5) if second_status != 0 or owner.is_alive() or statuses.get(timeout=1) != 0: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), audit_mode], check=False, capture_output=True, text=True, timeout=10, ) assert result.returncode == 0, result.stderr @NEEDS_FORK # pragma: needs fork @pytest.mark.parametrize( "event", [ pytest.param("os.fork", id="fork"), pytest.param("os.forkpty", id="forkpty"), pytest.param("_posixsubprocess.fork_exec", id="fork-exec"), ], ) def test_audit_rejects_process_creation_inside_descriptor_transition( tmp_path: Path, event: Literal["os.fork", "os.forkpty", "_posixsubprocess.fork_exec"], ) -> None: class AuditedLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None if event == "_posixsubprocess.fork_exec": sys.audit(event, (), (), None) else: sys.audit(event) _release = _acquire with pytest.raises(RuntimeError, match="unsafe while filelock is changing descriptor ownership"): AuditedLock(str(tmp_path / "audit.lock"), is_singleton=False).acquire(timeout=0) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "fork_mode", [ pytest.param("rejected-audit", id="rejected-audit-hook"), pytest.param( "fork1", marks=NEEDS_FORK1, id="fork1", ), ], ) def test_fork_without_audit_guard_snapshots_current_transition( tmp_path: Path, fork_mode: Literal["rejected-audit", "fork1"] ) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF if sys.argv[1] == "rejected-audit": # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def reject_filelock_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock fork = os.fork if sys.argv[1] == "rejected-audit" else os.fork1 class ForkingLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) child_pid = fork() if child_pid == 0: try: os.fstat(descriptor) except OSError as exception: os._exit(0 if exception.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = ForkingLock(sys.argv[2], is_singleton=False) lock.acquire() lock.release() raise SystemExit(lock.child_status) """ result = subprocess.run( [sys.executable, "-c", script, fork_mode, str(tmp_path / "transition.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.skipif(not has_fcntl, reason="fcntl.flock required") @pytest.mark.skipif(sys.implementation.name != "cpython", reason="CPython fcntl audit event required") @pytest.mark.parametrize( "fork_mode", [ pytest.param("rejected-audit", id="rejected-audit-hook"), pytest.param( "fork1", marks=NEEDS_FORK1, id="fork1", ), ], ) def test_native_descriptor_is_visible_during_flock_audit( tmp_path: Path, fork_mode: Literal["rejected-audit", "fork1"] ) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF child_status = -1 forked = False # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def audit_hook(event: str, args: tuple[object, ...]) -> None: global child_status, forked if event == "sys.addaudithook" and sys.argv[1] == "rejected-audit": raise RuntimeError if event != "fcntl.flock" or forked: return forked = True descriptor = args[0] child_pid = (os.fork if sys.argv[1] == "rejected-audit" else os.fork1)() if child_pid == 0: try: os.fstat(descriptor) except OSError as exception: os._exit(0 if exception.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) child_status = os.waitstatus_to_exitcode(status) sys.addaudithook(audit_hook) from filelock import FileLock lock = FileLock(sys.argv[2], is_singleton=False) lock.acquire() lock.release() raise SystemExit(child_status) """ result = subprocess.run( [sys.executable, "-c", script, fork_mode, str(tmp_path / "native.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_unwinds_pre_fork_transition_before_new_acquire(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from errno import EBADF # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def reject_filelock_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock class ForkingLock(BaseFileLock): child_pid = -1 def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) self.child_pid = os.fork() def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None first = ForkingLock(sys.argv[1], is_singleton=False) try: first.acquire() except RuntimeError as exception: if first.child_pid != 0 or "inherited across fork" not in str(exception): raise class ProbeLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) child_pid = os.fork() if child_pid == 0: try: os.fstat(descriptor) except OSError as error: os._exit(0 if error.errno == EBADF else 1) os._exit(1) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None probe = ProbeLock(sys.argv[2], is_singleton=False) probe.acquire() probe.release() os._exit(probe.child_status) _, status = os.waitpid(first.child_pid, 0) first.release() raise SystemExit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [ sys.executable, "-c", script, str(tmp_path / "first.lock"), str(tmp_path / "probe.lock"), ], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_overlapping_coroutine_transitions_close_every_pending_descriptor(tmp_path: Path) -> None: script = """ from __future__ import annotations import asyncio import os import sys from errno import EBADF # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def reject_filelock_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseAsyncFileLock both_pending = asyncio.Event() allow_return = asyncio.Event() descriptors: list[int] = [] child_status = -1 class OverlapLock(BaseAsyncFileLock): async def _acquire(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported global child_status descriptor = self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) descriptors.append(descriptor) if len(descriptors) == 2: both_pending.set() await both_pending.wait() if self.lock_file != sys.argv[1]: await allow_return.wait() return child_pid = os.fork() if child_pid == 0: for inherited in descriptors: try: os.fstat(inherited) except OSError as exception: if exception.errno == EBADF: continue os._exit(1) os._exit(0) _, status = os.waitpid(child_pid, 0) child_status = os.waitstatus_to_exitcode(status) allow_return.set() async def _release(self) -> None: # ty: ignore[invalid-method-override] # coroutine backends are supported os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None async def main() -> None: first = OverlapLock(sys.argv[1], thread_local=False, is_singleton=False, run_in_executor=False) second = OverlapLock(sys.argv[2], thread_local=False, is_singleton=False, run_in_executor=False) await asyncio.gather(first.acquire(timeout=0), second.acquire(timeout=0)) await asyncio.gather(first.release(), second.release()) raise SystemExit(child_status) asyncio.run(main()) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "first.lock"), str(tmp_path / "second.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_pending_descriptor_with_unknown_identity_is_preserved_in_child(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def reject_filelock_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock real_fstat = os.fstat class PendingLock(BaseFileLock): child_status = -1 def _acquire(self) -> None: descriptor = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) self._context.pending_lock_file_fd = descriptor def fail_pending_identity(fd: int) -> os.stat_result: if fd == descriptor: raise OSError("identity unavailable") return real_fstat(fd) os.fstat = fail_pending_identity child_pid = os.fork() os.fstat = real_fstat if child_pid == 0: real_fstat(descriptor) raise SystemExit(0) _, status = os.waitpid(child_pid, 0) self.child_status = os.waitstatus_to_exitcode(status) stat_result = real_fstat(descriptor) self._context.pending_lock_file_fd = None self._context.lock_file_fd = descriptor self._context.lock_file_fd_identity = stat_result.st_dev, stat_result.st_ino def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = PendingLock(sys.argv[1], is_singleton=False) lock.acquire(timeout=0) lock.release() raise SystemExit(lock.child_status) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "pending.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_replaces_parameter_model_mutex_held_during_construction(tmp_path: Path) -> None: script = """ from __future__ import annotations import inspect import os import sys import threading import warnings from collections.abc import Callable from queue import Queue warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) # Audit arguments are heterogeneous interpreter-owned values, so object is their only accurate common type. def reject_filelock_hook(event: str, _args: tuple[object, ...]) -> None: if event == "sys.addaudithook": raise RuntimeError sys.addaudithook(reject_filelock_hook) from filelock import BaseFileLock parent_pid = os.getpid() forked = False paused = threading.Event() release = threading.Event() children: Queue[int] = Queue() real_signature = inspect.signature class BlockingLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None class ChildLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None def blocking_signature(target: Callable[..., BaseFileLock | None]) -> inspect.Signature: global forked if target is BlockingLock.__init__ and not forked: forked = True child_pid = os.fork() if child_pid == 0: ChildLock(sys.argv[2], is_singleton=False) os._exit(0) children.put(child_pid) paused.set() if not release.wait(timeout=5): raise RuntimeError("parent construction was not released") return real_signature(target) inspect.signature = blocking_signature def construct() -> None: BlockingLock(sys.argv[1], is_singleton=False) worker = threading.Thread(target=construct) worker.start() if not paused.wait(timeout=5): sys.exit(1) child_pid = children.get(timeout=1) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) if os.waitstatus_to_exitcode(status) != 0 or worker.is_alive() or os.getpid() != parent_pid: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), str(tmp_path / "child.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_registration_transition_completes_after_fork_closes_admission(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys import threading import warnings from queue import Queue warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) admission_waiting = threading.Event() real_condition_wait = threading.Condition.wait def observed_wait(condition: threading.Condition, timeout: float | None = None) -> bool: if threading.current_thread().name == "forker": admission_waiting.set() return real_condition_wait(condition, timeout) threading.Condition.wait = observed_wait from filelock import BaseFileLock entered = threading.Event() release = threading.Event() failures: Queue[str] = Queue() fork_status: Queue[int] = Queue() class BlockingLock(BaseFileLock): def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) entered.set() if not release.wait(timeout=5): raise RuntimeError("backend acquisition was not released") def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None lock = BlockingLock(sys.argv[1], is_singleton=False) def acquire() -> None: try: lock.acquire(timeout=0) except BaseException as exception: failures.put(f"acquire: {exception!r}") def fork() -> None: try: child_pid = os.fork() if child_pid == 0: os._exit(0) _, status = os.waitpid(child_pid, 0) fork_status.put(os.waitstatus_to_exitcode(status)) except BaseException as exception: failures.put(f"fork: {exception!r}") worker = threading.Thread(target=acquire, name="acquirer") worker.start() if not entered.wait(timeout=5): sys.exit(1) forker = threading.Thread(target=fork, name="forker") forker.start() if not admission_waiting.wait(timeout=5): sys.exit(2) release.set() worker.join(timeout=5) forker.join(timeout=5) if worker.is_alive() or forker.is_alive() or not failures.empty() or fork_status.get(timeout=1) != 0: sys.exit(3) lock.release() """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "admission.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.skipif(not has_fcntl, reason="fcntl.flock required") def test_cancelled_async_acquire_unregisters_reused_descriptor(tmp_path: Path) -> None: script = """ from __future__ import annotations import asyncio import os import sys import threading import warnings from filelock import AsyncFileLock warnings.filterwarnings("ignore", message=".*multi-threaded, use of fork.*", category=DeprecationWarning) async def main() -> int: callback_started = asyncio.Event() finish_callback = threading.Event() loop = asyncio.get_running_loop() descriptor = -1 identity = (-1, -1) def block_after_acquire(fd: int) -> None: nonlocal descriptor, identity descriptor = fd stat_result = os.fstat(fd) identity = stat_result.st_dev, stat_result.st_ino loop.call_soon_threadsafe(callback_started.set) if not finish_callback.wait(timeout=5): raise RuntimeError("acquisition callback was not released") lock = AsyncFileLock(sys.argv[1], on_acquired=block_after_acquire) acquire_task = asyncio.create_task(lock.acquire()) await asyncio.wait_for(callback_started.wait(), timeout=5) acquire_task.cancel() finish_callback.set() try: await acquire_task except asyncio.CancelledError: pass else: return 1 if lock.is_locked or lock.lock_counter != 0: return 2 path_stat = os.stat(sys.argv[1]) if (path_stat.st_dev, path_stat.st_ino) != identity: return 3 occupant = os.open(os.devnull, os.O_RDONLY) if occupant != descriptor: os.dup2(occupant, descriptor) source = os.open(sys.argv[1], os.O_RDWR) if source == descriptor: return 4 if (source_stat := os.fstat(source)).st_dev != identity[0] or source_stat.st_ino != identity[1]: return 5 os.dup2(source, descriptor) os.close(source) if occupant != descriptor: os.close(occupant) # The canceled acquire ran the flock backend in the default executor, leaving an idle worker thread behind. Join it # before forking so the child is single-threaded: NetBSD deadlocks a child forked from a multi-threaded process. await loop.shutdown_default_executor() child_pid = os.fork() if child_pid == 0: try: replacement_stat = os.fstat(descriptor) except OSError: os._exit(1) os._exit(0 if (replacement_stat.st_dev, replacement_stat.st_ino) == identity else 2) _, status = os.waitpid(child_pid, 0) os.close(descriptor) return os.waitstatus_to_exitcode(status) raise SystemExit(asyncio.run(main())) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "canceled.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_parent_callback_rejects_reentrant_singleton_construction(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from collections.abc import Callable active_constructor: Callable[[], BaseFileLock | SoftReadWriteLock] | None = None callback_errors: list[str] = [] callback_instances: list[BaseFileLock | SoftReadWriteLock] = [] def construct_in_parent_callback() -> None: if active_constructor is None: return try: callback_instances.append(active_constructor()) except RuntimeError as exception: callback_errors.append(str(exception)) os.register_at_fork(after_in_parent=construct_in_parent_callback) from filelock import BaseFileLock, SoftReadWriteLock fork_in_progress = False children: list[int] = [] def fork_once() -> None: global fork_in_progress if fork_in_progress: return fork_in_progress = True child_pid = os.fork() if child_pid == 0: os._exit(0) children.append(child_pid) fork_in_progress = False class ForkingFileLock(BaseFileLock): def __init__( self, lock_file: str, timeout: float = -1, thread_local: bool = True, *, is_singleton: bool = False ) -> None: fork_once() super().__init__(lock_file, timeout, thread_local=thread_local, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None class ForkingSoftReadWriteLock(SoftReadWriteLock): def __init__( self, lock_file: str, timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> None: fork_once() super().__init__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) file_path, read_write_path = sys.argv[1:] active_constructor = lambda: ForkingFileLock(file_path, thread_local=False, is_singleton=True) file_lock = ForkingFileLock(file_path, thread_local=False, is_singleton=True) active_constructor = lambda: ForkingSoftReadWriteLock(read_write_path, is_singleton=True) read_write_lock = ForkingSoftReadWriteLock(read_write_path, is_singleton=True) active_constructor = None statuses = [os.waitstatus_to_exitcode(os.waitpid(child_pid, 0)[1]) for child_pid in children] if ( statuses != [0, 0] or callback_instances or len(callback_errors) != 2 or not all("Singleton lock construction is already active" in error for error in callback_errors) or ForkingFileLock(file_path, thread_local=False, is_singleton=True) is not file_lock or ForkingSoftReadWriteLock(read_write_path, is_singleton=True) is not read_write_lock ): sys.exit(1) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "file.lock"), str(tmp_path / "read-write.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_singleton_construction_crossing_fork_does_not_poison_child_cache(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from filelock import BaseFileLock parent_pid = os.getpid() forked = False children: list[int] = [] class ForkingLock(BaseFileLock): def __init__(self, lock_file: str, thread_local: bool = True, *, is_singleton: bool = False) -> None: global forked if not forked: forked = True child_pid = os.fork() if child_pid != 0: children.append(child_pid) super().__init__(lock_file, thread_local=thread_local, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = None def _release(self) -> None: self._context.lock_file_fd = None try: parent_lock = ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) except RuntimeError as exception: if os.getpid() == parent_pid or "Lock construction cannot continue after fork" not in str(exception): raise child_lock = ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) raise SystemExit(0 if ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) is child_lock else 1) _, status = os.waitpid(children[0], 0) if ( os.waitstatus_to_exitcode(status) != 0 or ForkingLock(sys.argv[1], thread_local=False, is_singleton=True) is not parent_lock ): sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_soft_read_write_construction_crossing_fork_does_not_poison_child_cache(tmp_path: Path) -> None: script = """ from __future__ import annotations import os import sys from filelock import SoftReadWriteLock parent_pid = os.getpid() forked = False children: list[int] = [] class ForkingLock(SoftReadWriteLock): def __init__( self, lock_file: str, timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, heartbeat_interval: float = 30.0, stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> None: global forked if not forked: forked = True child_pid = os.fork() if child_pid != 0: children.append(child_pid) super().__init__( lock_file, timeout, blocking=blocking, is_singleton=is_singleton, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) try: parent_lock = ForkingLock(sys.argv[1], is_singleton=True) except RuntimeError as exception: if os.getpid() == parent_pid or "Lock construction cannot continue after fork" not in str(exception): raise child_lock = ForkingLock(sys.argv[1], is_singleton=True) child_cached = ForkingLock(sys.argv[1], is_singleton=True) child_lock.close() raise SystemExit(0 if child_cached is child_lock else 1) _, status = os.waitpid(children[0], 0) parent_cached = ForkingLock(sys.argv[1], is_singleton=True) parent_lock.close() if os.waitstatus_to_exitcode(status) != 0 or parent_cached is not parent_lock: sys.exit(2) """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "singleton.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") def test_fork_exec_from_another_thread_remains_available(tmp_path: Path) -> None: entered, release, finished = threading.Event(), threading.Event(), threading.Event() class BlockingTransitionLock(BaseFileLock): def _acquire(self) -> None: entered.set() release.wait(timeout=5) self._context.lock_file_fd = None _release = _acquire def acquire() -> None: try: BlockingTransitionLock(str(tmp_path / "transition.lock"), is_singleton=False).acquire(timeout=0) except Timeout: finished.set() worker = threading.Thread(target=acquire) worker.start() assert entered.wait(timeout=5) sys.audit("_posixsubprocess.fork_exec", (), (), None) result = subprocess.run([sys.executable, "-c", ""], check=False, timeout=5) release.set() worker.join(timeout=5) assert (result.returncode, finished.is_set(), worker.is_alive()) == (0, True, False) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_replaces_singleton_mutex_held_by_vanished_thread(tmp_path: Path) -> None: entered, release = threading.Event(), threading.Event() parent_pid = os.getpid() class BlockingLock(BaseFileLock): def __init__(self, lock_file: str, *, is_singleton: bool = False) -> None: if os.getpid() == parent_pid: entered.set() release.wait(timeout=5) super().__init__(lock_file, thread_local=False, is_singleton=is_singleton) def _acquire(self) -> None: self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None worker = threading.Thread(target=BlockingLock, args=(str(tmp_path / "mutex.lock"),), kwargs={"is_singleton": True}) worker.start() assert entered.wait(timeout=5) if (child_pid := fork_process()) == 0: # pragma: forked child child_lock = BlockingLock(str(tmp_path / "mutex.lock"), is_singleton=True) child_lock.acquire(timeout=0) child_lock.release() exit_child(0) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), worker.is_alive()) == (0, False) filelock-3.32.2/tests/test_fork_ownership.py0000644000000000000000000004247413615410400016136 0ustar00from __future__ import annotations import asyncio import gc import os import subprocess # ruff:ignore[suspicious-subprocess-import] # fresh interpreter must register the callback before importing filelock import sys import threading from errno import EBADF from queue import Queue from stat import S_ISDIR from typing import TYPE_CHECKING, Final, Literal, NoReturn import pytest from filelock import ( AcquireReturnProxy, AsyncFileLock, AsyncSoftFileLock, BaseAsyncFileLock, BaseFileLock, FileLock, SoftFileLock, SoftReadWriteLock, Timeout, ) from tests.capability_marks import NEEDS_FORK, NEEDS_REGISTER_AT_FORK from tests.fork_helpers import exit_child, fork_process if sys.version_info >= (3, 11): from builtins import BaseExceptionGroup # pragma: >=3.11 cover else: # pragma: <3.11 cover from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture _FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" ) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( ("lock_type", "action"), [ pytest.param(FileLock, "release", id="native-release"), pytest.param(FileLock, "context", id="native-context"), pytest.param(FileLock, "collect", id="native-collect"), pytest.param(SoftFileLock, "release", id="soft-release"), pytest.param(SoftFileLock, "context", id="soft-context"), pytest.param(SoftFileLock, "collect", id="soft-collect"), ], ) def test_child_cleanup_preserves_parent_lock( tmp_path: Path, lock_type: type[BaseFileLock], action: Literal["release", "context", "collect"], ) -> None: path = str(tmp_path / "parent.lock") lock = lock_type(path, thread_local=False, is_singleton=False) proxy = lock.acquire() def cleanup_child() -> NoReturn: _run_child_cleanup(lock, proxy, action) child_pid = fork_process(cleanup_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert not _probe_lock(lock_type, path) lock.release() assert _probe_lock(lock_type, path) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( ("async_lock_type", "sync_lock_type"), [ pytest.param(AsyncFileLock, FileLock, id="native"), pytest.param(AsyncSoftFileLock, SoftFileLock, id="soft"), ], ) def test_async_child_release_preserves_parent_lock( tmp_path: Path, async_lock_type: type[BaseAsyncFileLock], sync_lock_type: type[BaseFileLock], ) -> None: path = str(tmp_path / "parent.lock") lock = async_lock_type(path, thread_local=False, is_singleton=False) asyncio.run(lock.acquire()) def release_child() -> NoReturn: state = lock.is_locked, lock.lock_counter asyncio.run(lock.release(force=True)) exit_child(0 if state == (False, 0) else 1) child_pid = fork_process(release_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 assert not _probe_lock(sync_lock_type, path) asyncio.run(lock.release()) assert _probe_lock(sync_lock_type, path) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_soft_read_write_resets_older_same_path_instance(tmp_path: Path) -> None: path = str(tmp_path / "parent.lock") older = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) newer = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) del newer gc.collect() older.acquire_write() def inherited_child() -> NoReturn: with pytest.raises(RuntimeError, match="invalidated by fork"): older.acquire_read(timeout=0) older.release() exit_child(0) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 contender = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5) with pytest.raises(Timeout): contender.acquire_write(timeout=0) contender.close() older.release() older.close() @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_closes_descriptor_held_by_vanished_thread(tmp_path: Path) -> None: path = str(tmp_path / "parent.lock") descriptor: Queue[int] = Queue() acquired, release = threading.Event(), threading.Event() def hold_lock() -> None: lock = FileLock(path, thread_local=True, is_singleton=False, on_acquired=descriptor.put) with lock: acquired.set() release.wait() worker = threading.Thread(target=hold_lock) worker.start() assert acquired.wait(timeout=5) fd = descriptor.get(timeout=5) def descriptor_child() -> NoReturn: _assert_descriptor_closed(fd) child_pid = fork_process(descriptor_child) _, status = os.waitpid(child_pid, 0) release.set() worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), worker.is_alive()) == (0, False) @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_waits_for_descriptor_registration(tmp_path: Path, mocker: MockerFixture) -> None: path = str(tmp_path / "parent.lock") lock = FileLock(path, thread_local=False, is_singleton=False) entered, proceed, acquired = threading.Event(), threading.Event(), threading.Event() descriptors: list[int] = [] real_fstat = os.fstat def delayed_fstat(fd: int) -> os.stat_result: if threading.current_thread() is worker and not entered.is_set(): descriptors.append(fd) entered.set() proceed.wait() return real_fstat(fd) mocker.patch("os.fstat", side_effect=delayed_fstat) def acquire_lock() -> None: lock.acquire() acquired.set() worker = threading.Thread(target=acquire_lock) worker.start() assert entered.wait(timeout=5) timer = threading.Timer(0.05, proceed.set) timer.start() def descriptor_child() -> NoReturn: _assert_descriptor_closed(descriptors[0]) child_pid = fork_process(descriptor_child) _, status = os.waitpid(child_pid, 0) timer.join(timeout=5) worker.join(timeout=5) assert (os.waitstatus_to_exitcode(status), acquired.is_set(), worker.is_alive()) == (0, True, False) lock.release() @NEEDS_REGISTER_AT_FORK def test_unrelated_acquisitions_reach_filesystem_boundary_concurrently( # pragma: needs fork tmp_path: Path, mocker: MockerFixture ) -> None: boundary = threading.Barrier(3, timeout=5) real_fstat = os.fstat def wait_at_boundary(fd: int) -> os.stat_result: # Only the two named workers stat while the patch is installed, so the guard never takes its false arc. if threading.current_thread().name.startswith("concurrent-acquire-"): # pragma: no branch boundary.wait() return real_fstat(fd) mocker.patch("os.fstat", side_effect=wait_at_boundary) def acquire_lock(path: str) -> None: with FileLock(path, is_singleton=False): pass workers = [ threading.Thread( target=acquire_lock, args=(str(tmp_path / f"{index}.lock"),), name=f"concurrent-acquire-{index}", ) for index in range(2) ] for worker in workers: worker.start() try: boundary.wait() finally: for worker in workers: worker.join(timeout=5) assert [worker.is_alive() for worker in workers] == [False, False] @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize("lock_type", [pytest.param(FileLock, id="native"), pytest.param(SoftFileLock, id="soft")]) def test_child_singleton_registry_drops_parent_instance(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: path = str(tmp_path / "parent.lock") parent_lock = lock_type(path, is_singleton=True) def singleton_child() -> NoReturn: child_lock = lock_type(path, is_singleton=True) exit_child(0 if child_lock is not parent_lock else 1) child_pid = fork_process(singleton_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize("lock_type", [pytest.param(FileLock, id="native"), pytest.param(SoftFileLock, id="soft")]) def test_inherited_idle_lock_rejects_acquire(tmp_path: Path, lock_type: type[BaseFileLock]) -> None: lock = lock_type(str(tmp_path / "parent.lock"), is_singleton=False) def inherited_child() -> NoReturn: try: lock.acquire(timeout=0) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "lock_type", [pytest.param(AsyncFileLock, id="native"), pytest.param(AsyncSoftFileLock, id="soft")] ) def test_inherited_idle_async_lock_rejects_acquire(tmp_path: Path, lock_type: type[BaseAsyncFileLock]) -> None: lock = lock_type(str(tmp_path / "parent.lock"), is_singleton=False) def inherited_child() -> NoReturn: try: asyncio.run(lock.acquire(timeout=0)) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) child_pid = fork_process(inherited_child) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_fork_from_on_acquired_invalidates_child_acquire(tmp_path: Path) -> None: path = str(tmp_path / "parent.lock") fork_result = -1 lock: FileLock def inherited_child() -> NoReturn: try: lock.acquire(timeout=0) except RuntimeError as exception: exit_child(0 if "inherited across fork" in str(exception) else 1) exit_child(1) def fork_from_hook(_fd: int) -> None: nonlocal fork_result fork_result = fork_process(inherited_child) lock = FileLock(path, thread_local=False, is_singleton=False, on_acquired=fork_from_hook) lock.acquire() _, status = os.waitpid(fork_result, 0) assert (os.waitstatus_to_exitcode(status), _probe_lock(FileLock, path)) == (0, False) lock.release() @NEEDS_REGISTER_AT_FORK def test_reader_directory_registration_failure_closes_descriptor( # pragma: needs fork tmp_path: Path, mocker: MockerFixture ) -> None: lock = SoftReadWriteLock( str(tmp_path / "parent.lock"), is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, ) real_fstat = os.fstat directory_fds: list[int] = [] def fail_directory_fstat(fd: int) -> os.stat_result: stat_result = real_fstat(fd) assert S_ISDIR(stat_result.st_mode) directory_fds.append(fd) msg = "directory identity unavailable" raise OSError(msg) mocker.patch("os.fstat", side_effect=fail_directory_fstat) with pytest.raises(OSError, match="directory identity unavailable"): lock.acquire_read() with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): real_fstat(directory_fds[0]) lock.close() @NEEDS_REGISTER_AT_FORK def test_reader_directory_registration_and_close_errors_are_grouped( # pragma: needs fork tmp_path: Path, mocker: MockerFixture ) -> None: lock = SoftReadWriteLock( str(tmp_path / "parent.lock"), is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, ) real_close, real_fstat = os.close, os.fstat directory_fds: list[int] = [] def fail_directory_fstat(fd: int) -> os.stat_result: stat_result = real_fstat(fd) assert S_ISDIR(stat_result.st_mode) directory_fds.append(fd) msg = "directory identity unavailable" raise OSError(msg) def fail_directory_close(fd: int) -> None: assert fd == directory_fds[0] msg = "directory close failed" raise OSError(msg) mocker.patch("os.fstat", side_effect=fail_directory_fstat) mocker.patch("os.close", side_effect=fail_directory_close) with pytest.raises(BaseExceptionGroup) as info: lock.acquire_read() real_close(directory_fds[0]) lock.close() assert [(type(error), str(error)) for error in info.value.exceptions] == [ (OSError, "directory identity unavailable"), (OSError, "directory close failed"), ] @NEEDS_FORK # pragma: needs fork @_FORK_WARNING def test_child_callback_registered_before_filelock_can_acquire(tmp_path: Path) -> None: script = """ import os import sys parent_path, child_path = sys.argv[1:] callback_succeeded = False def acquire_in_early_child_callback() -> None: global callback_succeeded with FileLock(child_path, is_singleton=False): pass callback_succeeded = True os.register_at_fork(after_in_child=acquire_in_early_child_callback) from filelock import FileLock, Timeout parent = FileLock(parent_path, is_singleton=False) parent.acquire() child_pid = os.fork() if child_pid == 0: os._exit(0 if callback_succeeded else 3) _, status = os.waitpid(child_pid, 0) if os.waitstatus_to_exitcode(status) != 0: sys.exit(1) contender = FileLock(parent_path, is_singleton=False) try: contender.acquire(timeout=0) except Timeout: pass else: sys.exit(2) parent.release() """ result = subprocess.run( [sys.executable, "-c", script, str(tmp_path / "parent.lock"), str(tmp_path / "child.lock")], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @_FORK_WARNING @pytest.mark.parametrize( "kind", [ pytest.param("native", id="native"), pytest.param("soft", id="soft"), pytest.param("soft-rw", id="soft-read-write"), ], ) def test_child_interpreter_exit_preserves_parent_lock( tmp_path: Path, kind: Literal["native", "soft", "soft-rw"] ) -> None: script = """ from __future__ import annotations import os import sys from filelock import BaseFileLock, FileLock, SoftFileLock, SoftReadWriteLock, Timeout def acquire(lock: BaseFileLock | SoftReadWriteLock, timeout: float = -1) -> None: if isinstance(lock, SoftReadWriteLock): lock.acquire_write(timeout=timeout) else: lock.acquire(timeout=timeout) def release(lock: BaseFileLock | SoftReadWriteLock) -> None: if isinstance(lock, SoftReadWriteLock): lock.release() lock.close() else: lock.release() kind, path = sys.argv[1:] lock: BaseFileLock | SoftReadWriteLock if kind == "soft-rw": lock = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=30) elif kind == "soft": lock = SoftFileLock(path, is_singleton=False) else: lock = FileLock(path, is_singleton=False) acquire(lock) child_pid = os.fork() if child_pid == 0: sys.exit(0) _, status = os.waitpid(child_pid, 0) if os.waitstatus_to_exitcode(status) != 0: sys.exit(1) contender_pid = os.fork() if contender_pid == 0: contender: BaseFileLock | SoftReadWriteLock if kind == "soft-rw": contender = SoftReadWriteLock(path, is_singleton=False, heartbeat_interval=0.1, stale_threshold=30) elif kind == "soft": contender = SoftFileLock(path, is_singleton=False) else: contender = FileLock(path, is_singleton=False) try: acquire(contender, timeout=0) except Timeout: os._exit(0) release(contender) os._exit(2) _, status = os.waitpid(contender_pid, 0) release(lock) sys.exit(os.waitstatus_to_exitcode(status)) """ result = subprocess.run( [sys.executable, "-c", script, kind, str(tmp_path / "parent.lock")], check=False, capture_output=True, text=True, timeout=15, ) assert result.returncode == 0, result.stderr def _run_child_cleanup( lock: BaseFileLock, proxy: AcquireReturnProxy, action: Literal["release", "context", "collect"], ) -> NoReturn: assert (lock.is_locked, lock.lock_counter) == (False, 0) if action == "release": lock.release(force=True) elif action == "context": proxy.__exit__(None, None, None) else: del proxy del lock gc.collect() exit_child(0) def _probe_lock(lock_type: type[BaseFileLock], path: str) -> bool: # pragma: needs fork read_fd, write_fd = os.pipe() def probe_child() -> NoReturn: os.close(read_fd) candidate = lock_type(path, is_singleton=False) try: candidate.acquire(timeout=0) except Timeout: acquired = False else: acquired = True candidate.release() os.write(write_fd, b"1" if acquired else b"0") os.close(write_fd) exit_child(0) child_pid = fork_process(probe_child) os.close(write_fd) result = os.read(read_fd, 1) os.close(read_fd) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 return result == b"1" def _assert_descriptor_closed(fd: int) -> NoReturn: try: os.fstat(fd) except OSError as exception: exit_child(0 if exception.errno == EBADF else 1) exit_child(1) filelock-3.32.2/tests/test_fork_registries.py0000644000000000000000000000534313615410400016272 0ustar00from __future__ import annotations import gc import os import subprocess # ruff:ignore[suspicious-subprocess-import] # interpreter exit exercises the atexit registry import sys import weakref from dataclasses import dataclass from typing import TYPE_CHECKING, NoReturn from filelock import BaseFileLock from tests.capability_marks import NEEDS_CLASS_COLLECTION, NEEDS_FORK from tests.fork_helpers import exit_child, fork_process if TYPE_CHECKING: from pathlib import Path @NEEDS_FORK def test_equal_unhashable_locks_reset_independently(tmp_path: Path) -> None: # pragma: win32 no cover @dataclass(eq=True, init=False) # pragma: win32 no cover class EqualLock(BaseFileLock): # pragma: win32 no cover def _acquire(self) -> None: # pragma: win32 no cover self._context.lock_file_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR, 0o600) def _release(self) -> None: # pragma: win32 no cover os.close(self._context.lock_file_fd if self._context.lock_file_fd is not None else -1) self._context.lock_file_fd = None first = EqualLock(str(tmp_path / "first.lock"), is_singleton=False) second = EqualLock(str(tmp_path / "second.lock"), is_singleton=False) first.acquire() second.acquire() def reset_child() -> NoReturn: state = first.is_locked, first.lock_counter, second.is_locked, second.lock_counter exit_child(0 if state == (False, 0, False, 0) else 1) child_pid = fork_process(reset_child) _, status = os.waitpid(child_pid, 0) first.release() second.release() assert os.waitstatus_to_exitcode(status) == 0 def test_equal_unhashable_soft_read_write_locks_survive_atexit_registry(tmp_path: Path) -> None: script = """ from __future__ import annotations import sys from dataclasses import dataclass from filelock import SoftReadWriteLock @dataclass(eq=True, init=False) class EqualLock(SoftReadWriteLock): pass locks = [EqualLock(path, is_singleton=False, heartbeat_interval=1, stale_threshold=30) for path in sys.argv[1:]] for lock in locks: lock.acquire_write() """ paths = [tmp_path / "first.lock", tmp_path / "second.lock"] result = subprocess.run( [sys.executable, "-c", script, *(str(path) for path in paths)], check=False, capture_output=True, text=True, timeout=10, ) assert (result.returncode, [path.with_name(f"{path.name}.write").exists() for path in paths]) == (0, [False, False]) @NEEDS_CLASS_COLLECTION def test_dynamic_lock_class_can_be_collected() -> None: class EphemeralLock(BaseFileLock): _acquire = _release = BaseFileLock._acquire class_ref = weakref.ref(EphemeralLock) del EphemeralLock gc.collect() assert class_ref() is None filelock-3.32.2/tests/test_lifetime_validation.py0000644000000000000000000001031413615410400017073 0ustar00from __future__ import annotations from typing import TYPE_CHECKING, Final, Literal, cast import pytest from filelock import AsyncFileLock, AsyncSoftFileLock, FileLock, SoftFileLock if TYPE_CHECKING: from pathlib import Path pytestmark: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") @pytest.mark.parametrize( "lock_type", [ pytest.param(FileLock, id="native-sync"), pytest.param(SoftFileLock, id="soft-sync"), pytest.param(AsyncFileLock, id="native-async"), pytest.param(AsyncSoftFileLock, id="soft-async"), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) @pytest.mark.parametrize( ("bad_value", "error_type", "message"), [ pytest.param(-1, ValueError, "finite and non-negative", id="negative-int"), pytest.param(-0.5, ValueError, "finite and non-negative", id="negative-float"), pytest.param(float("nan"), ValueError, "finite and non-negative", id="nan"), pytest.param(float("inf"), ValueError, "finite and non-negative", id="positive-infinity"), pytest.param(float("-inf"), ValueError, "finite and non-negative", id="negative-infinity"), pytest.param(True, TypeError, "lifetime must be", id="true"), pytest.param(False, TypeError, "lifetime must be", id="false"), pytest.param("5", TypeError, "lifetime must be", id="string"), pytest.param(b"5", TypeError, "lifetime must be", id="bytes"), pytest.param([1], TypeError, "lifetime must be", id="list"), pytest.param({1: 2}, TypeError, "lifetime must be", id="dict"), pytest.param(complex(1, 0), TypeError, "lifetime must be", id="complex"), ], ) def test_lifetime_rejects_invalid_value( lock_type: type[FileLock | AsyncFileLock], entry_point: Literal["constructor", "setter"], bad_value: str | bytes | list[int] | dict[int, int] | complex, error_type: type[ValueError | TypeError], message: str, tmp_path: Path, ) -> None: lock_path = tmp_path / "test.lock" lock = lock_type(lock_path) with pytest.raises(error_type, match=message): _set_lifetime(lock_type, entry_point, lock, cast("float", bad_value)) assert (lock.lifetime, lock_path.exists()) == (None, False) @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) @pytest.mark.parametrize( "value", [ pytest.param(None, id="none"), pytest.param(0, id="zero-int"), pytest.param(0.0, id="zero-float"), pytest.param(2.5, id="positive-float"), pytest.param(10**1000, id="large-int"), ], ) def test_lifetime_accepts_supported_value( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], value: float | None, tmp_path: Path, ) -> None: lock_path = tmp_path / "test.lock" if entry_point == "constructor": lock = lock_type(lock_path, lifetime=value) else: lock = lock_type(lock_path) lock.lifetime = value assert lock.lifetime == value @pytest.mark.parametrize( "bad_value", [ pytest.param(-1, id="negative"), pytest.param(float("nan"), id="nan"), pytest.param(float("inf"), id="infinity"), ], ) def test_lifetime_singleton_rejection_preserves_instance(bad_value: float, tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock = SoftFileLock(lock_path, is_singleton=True, lifetime=10) with pytest.raises(ValueError, match="finite and non-negative"): SoftFileLock(lock_path, is_singleton=True, lifetime=bad_value) assert SoftFileLock(lock_path, is_singleton=True, lifetime=10) is lock def _set_lifetime( lock_type: type[FileLock | AsyncFileLock], entry_point: Literal["constructor", "setter"], lock: FileLock | AsyncFileLock, value: float, ) -> None: if entry_point == "constructor": lock_type(lock.lock_file, lifetime=value) else: lock.lifetime = value filelock-3.32.2/tests/test_lock_expiry.py0000644000000000000000000001453613615410400015425 0ustar00from __future__ import annotations import os import socket import time from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncFileLock, AsyncSoftFileLock, FileLock, SoftFileLock, Timeout if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture _NATIVE_IGNORES: Final[str] = "lifetime is ignored" pytestmark: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings("ignore::filelock.SoftFileLockLifetimeWarning") def test_expired_soft_lock_is_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = SoftFileLock(lock_path, lifetime=0.1, timeout=1) with lock: assert lock.is_locked @pytest.mark.parametrize("lock_type", [FileLock, AsyncFileLock]) def test_native_lifetime_warns_and_is_ignored(lock_type: type[FileLock | AsyncFileLock], tmp_path: Path) -> None: with pytest.warns(UserWarning, match=_NATIVE_IGNORES): lock = lock_type(tmp_path / "test.lock", lifetime=10.0) assert lock.lifetime is None def test_native_lifetime_setter_warns_and_is_ignored(tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.warns(UserWarning, match=_NATIVE_IGNORES): lock.lifetime = 10.0 assert lock.lifetime is None def test_native_lifetime_does_not_break_live_holder(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" with FileLock(lock_path, timeout=0): os.utime(lock_path, (0, 0)) # a broken age-based break would evict this live holder with pytest.warns(UserWarning, match=_NATIVE_IGNORES): contender = FileLock(lock_path, lifetime=0.1, timeout=0.3) with pytest.raises(Timeout): contender.acquire() def test_soft_non_expired_lock_not_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n", encoding="utf-8") with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=9999, timeout=0.2).acquire() def test_soft_lifetime_none_no_expiry(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.write_text(f"{os.getpid()}\n{socket.gethostname()}\n", encoding="utf-8") os.utime(lock_path, (0, 0)) with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=None, timeout=0.2).acquire() def test_expired_lock_race_rename_fails(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) mocker.patch("filelock._util.Path.rename", side_effect=FileNotFoundError) with pytest.raises(TimeoutError): SoftFileLock(lock_path, lifetime=0.1, timeout=0.5).acquire() 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) lock.lifetime = 20.0 assert lock.lifetime == pytest.approx(20.0) lock.lifetime = None assert lock.lifetime is None def test_lifetime_default_none(tmp_path: Path) -> None: assert FileLock(tmp_path / "test.lock").lifetime is None @pytest.mark.parametrize("bad_value", [-1, -0.5, -1e9]) def test_lifetime_setter_rejects_negative_number(bad_value: float, tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(ValueError, match="non-negative"): lock.lifetime = bad_value @pytest.mark.parametrize("bad_value", ["5", b"5", [1], {1: 2}, complex(1, 0)]) def test_lifetime_setter_rejects_non_numeric( bad_value: str | bytes | list[int] | dict[int, int] | complex, tmp_path: Path ) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(TypeError, match="lifetime must be"): lock.lifetime = bad_value # ty: ignore[invalid-assignment] # non-numeric input to hit the setter's TypeError @pytest.mark.parametrize("bad_value", [True, False]) # bool is an int subclass; reject it so it can't read as 1s/0s def test_lifetime_setter_rejects_bool(bad_value: bool, tmp_path: Path) -> None: lock = FileLock(tmp_path / "test.lock") with pytest.raises(TypeError, match="lifetime must be"): lock.lifetime = bad_value @pytest.mark.parametrize("value", [0, 0.0]) def test_lifetime_setter_accepts_zero(value: float, tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock") lock.lifetime = value assert lock.lifetime == 0 def test_lifetime_singleton_mismatch(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock1 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) assert lock1.lifetime == pytest.approx(10.0) with pytest.raises(ValueError, match="lifetime"): SoftFileLock(lock_path, is_singleton=True, lifetime=20.0) def test_lifetime_singleton_match(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock1 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) lock2 = SoftFileLock(lock_path, is_singleton=True, lifetime=10.0) assert lock1 is lock2 def test_lock_file_missing_during_expiry_check(tmp_path: Path) -> None: lock = SoftFileLock(tmp_path / "test.lock", lifetime=0.1, timeout=1) with lock: assert lock.is_locked def test_expired_lock_becomes_acquirable(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = SoftFileLock(lock_path, lifetime=0.5, timeout=1) with lock: assert lock.is_locked assert not lock.is_locked @pytest.mark.asyncio async def test_async_expired_lock_is_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() os.utime(lock_path, (0, 0)) lock = AsyncSoftFileLock(lock_path, lifetime=0.1, timeout=1) async with lock: assert lock.is_locked @pytest.mark.asyncio async def test_async_soft_non_expired_lock_not_broken(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" lock_path.touch() with pytest.raises(TimeoutError): await AsyncSoftFileLock(lock_path, lifetime=9999, timeout=0.2).acquire() def test_lock_mtime_updated_on_acquire(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" before = time.time() with SoftFileLock(lock_path, lifetime=60): assert lock_path.exists() # the soft marker exists while held assert lock_path.stat().st_mtime >= before - 1 filelock-3.32.2/tests/test_marker_records.py0000644000000000000000000001614313615410400016073 0ustar00from __future__ import annotations import os import time from typing import TYPE_CHECKING import pytest from filelock import SoftFileLease, Timeout from filelock._identity import process_start_token from filelock._marker import OwnerRecord, encode_marker if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.parametrize( "record", [ pytest.param("filelock/1\npid=1\nhost=h\nmode=lease\n", id="wrong-protocol"), pytest.param("filelock/2\npid=1\nhost=h\n", id="no-mode"), pytest.param("filelock/2\npid=1\nmode=lease\n", id="no-host"), pytest.param("filelock/2\npid=1\nhost=\nmode=lease\n", id="empty-host"), pytest.param("filelock/2\nhost=h\nmode=lease\n", id="no-pid"), pytest.param("filelock/2\npid=nine\nhost=h\nmode=lease\n", id="pid-not-a-number"), pytest.param("filelock/2\npid=0\nhost=h\nmode=lease\n", id="pid-below-range"), pytest.param("filelock/2\npid=99999999999\nhost=h\nmode=lease\n", id="pid-above-range"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\nbare-line\n", id="field-without-value"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\nduration=5\n", id="lease-without-token"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\n", id="lease-without-duration"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=0\n", id="lease-duration-zero"), pytest.param( "filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=soon\n", id="lease-duration-not-a-number" ), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=nan\n", id="lease-duration-nan"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=inf\n", id="lease-duration-inf"), pytest.param("filelock/2\npid=1\nhost=h\nmode=lease\ntoken=t\nduration=5\nstart=later\n", id="start-nan"), ], ) def test_unreadable_record_names_no_owner(tmp_path: Path, record: str) -> None: marker = tmp_path / "a.lock" marker.write_text(record, encoding="utf-8") assert SoftFileLease(str(marker), lease_duration=1).owner is None def test_encode_marker_omits_absent_lease_fields() -> None: # A non-lease owner carries no token or duration, so those lines never appear in the rendered marker. rendered = encode_marker(OwnerRecord(pid=7, hostname="h", mode="unknown")) assert rendered == b"filelock/2\npid=7\nhost=h\nmode=unknown\n" assert b"token=" not in rendered assert b"duration=" not in rendered def test_record_start_token_reads_back(tmp_path: Path) -> None: marker = tmp_path / "a.lock" record = "filelock/2\npid=4242\nhost=somehost\nmode=lease\ntoken=t\nduration=5\nstart=987654\n" marker.write_text(record, encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode, owner.start) == (4242, "somehost", "lease", 987654) def test_record_from_a_newer_filelock_still_names_its_owner(tmp_path: Path) -> None: # An unknown key is a field this version does not model yet; the owner it publishes must still read back. marker = tmp_path / "a.lock" record = "filelock/2\npid=4242\nhost=somehost\nmode=lease\ntoken=t\nduration=5\nstart-id=17\n" marker.write_text(record, encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode) == (4242, "somehost", "lease") @pytest.mark.parametrize("published", ["strict", "banana"]) def test_record_with_an_unknown_mode_names_its_owner(tmp_path: Path, published: str) -> None: # A mode this version does not implement states a contract a newer filelock published, not a corrupt record. marker = tmp_path / "a.lock" marker.write_text(f"filelock/2\npid=4242\nhost=somehost\nmode={published}\n", encoding="utf-8") owner = SoftFileLease(str(marker), lease_duration=1).owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode) == (4242, "somehost", "unknown") def test_held_lease_records_the_process_start_token(tmp_path: Path) -> None: marker = tmp_path / "a.lock" with SoftFileLease(str(marker), lease_duration=5) as lease: assert lease.owner is not None assert lease.owner.start == process_start_token(os.getpid()) def test_marker_without_start_token_omits_it(tmp_path: Path, mocker: MockerFixture) -> None: # A platform without a proven start time publishes no start field, and the record still reads back cleanly. mocker.patch("filelock._marker.process_start_token", return_value=None) marker = tmp_path / "a.lock" with SoftFileLease(str(marker), lease_duration=5) as lease: assert lease.owner is not None assert lease.owner.start is None assert "start=" not in marker.read_text(encoding="utf-8") def test_lease_treats_an_unreadable_record_as_contention(tmp_path: Path) -> None: # Only a peer that published a lease agreed to be superseded by age, so an unreadable record is never reclaimed. marker = tmp_path / "a.lock" marker.write_text("filelock/2\npid=1\nhost=h\nmode=banana\n", encoding="utf-8") with pytest.raises(Timeout): SoftFileLease(str(marker), lease_duration=0.3, heartbeat_interval=0.1, timeout=0.5).acquire() assert marker.exists() def test_lease_never_ages_out_an_unknown_contract(tmp_path: Path) -> None: # A marker naming a contract this version cannot interpret must outlive the malformed grace window: aging it out # would delete a lock whose owner never agreed to expire. marker = tmp_path / "a.lock" marker.write_text("filelock/2\npid=999999\nhost=nowhere\nmode=fenced\n", encoding="utf-8") stale = time.time() - 3600 os.utime(marker, (stale, stale)) with pytest.raises(Timeout): SoftFileLease(str(marker), lease_duration=0.3, heartbeat_interval=0.1, timeout=0.5).acquire() assert marker.exists() def test_lease_ages_out_a_malformed_record(tmp_path: Path) -> None: # The self-heal still applies to a record that states no contract at all, so corruption cannot wedge contenders. marker = tmp_path / "a.lock" marker.write_text("filelock/2\nnot-a-record\n", encoding="utf-8") stale = time.time() - 3600 os.utime(marker, (stale, stale)) with SoftFileLease(str(marker), lease_duration=5, timeout=1) as lease: assert lease.is_lock_held_by_us def test_marker_pid_and_owner_are_none_without_marker(tmp_path: Path) -> None: lease = SoftFileLease(tmp_path / "a") assert (lease.pid, lease.owner, lease.is_lock_held_by_us) == (None, None, False) def test_marker_force_break_removes_the_marker(tmp_path: Path) -> None: # force_break clears a marker whose holder is gone, so the file is on disk but not held open — the one case where # an unlink also succeeds on Windows, which refuses to remove a descriptor another handle still holds. marker = tmp_path / "a" marker.write_text("filelock/2\npid=1\nhost=h\nmode=lease\n", encoding="utf-8") assert marker.exists() SoftFileLease(marker).force_break() assert not marker.exists() filelock-3.32.2/tests/test_process_identity.py0000644000000000000000000000645613615410400016466 0ustar00from __future__ import annotations import os import socket import sys from errno import ENODEV, EPERM from typing import TYPE_CHECKING, Final import pytest from filelock._identity import host_name, owner_is_stale, process_alive, process_start_token if TYPE_CHECKING: from pytest_mock import MockerFixture _DEAD_PID: Final[int] = 2**22 + 1 _POSIX_ONLY: Final[pytest.MarkDecorator] = pytest.mark.skipif(sys.platform == "win32", reason="posix kill semantics") #: NetBSD and other POSIX platforms without a proven start-time source expose no token, so an owner carries none there. _NEEDS_START_TOKEN: Final[pytest.MarkDecorator] = pytest.mark.skipif( process_start_token(os.getpid()) is None, reason="this platform exposes no process start-time source" ) def test_host_name_matches_socket() -> None: assert host_name() == socket.gethostname() def test_process_alive_true_for_self() -> None: assert process_alive(os.getpid()) is True def test_process_alive_false_for_dead() -> None: assert process_alive(_DEAD_PID) is False @_POSIX_ONLY def test_process_alive_reads_permission_denied_as_alive(mocker: MockerFixture) -> None: # pragma: win32 no cover mocker.patch("filelock._identity.os.kill", side_effect=OSError(EPERM, "operation not permitted")) assert process_alive(_DEAD_PID) is True @_POSIX_ONLY def test_process_alive_reraises_unexpected_errno(mocker: MockerFixture) -> None: # pragma: win32 no cover mocker.patch("filelock._identity.os.kill", side_effect=OSError(ENODEV, "no such device")) with pytest.raises(OSError, match="no such device"): # pragma: win32 no cover process_alive(_DEAD_PID) @_NEEDS_START_TOKEN def test_process_start_token_is_int_for_self() -> None: assert isinstance(process_start_token(os.getpid()), int) def test_process_start_token_none_for_dead() -> None: assert process_start_token(_DEAD_PID) is None @pytest.mark.skipif(sys.platform != "darwin", reason="macOS sysctl probe") def test_darwin_sysctl_probe_failure_reads_no_token(mocker: MockerFixture) -> None: # pragma: darwin cover mocker.patch("filelock._identity._LIBC.sysctl", return_value=-1) assert process_start_token(os.getpid()) is None def test_owner_is_stale_foreign_host_never_reclaimed() -> None: assert owner_is_stale(os.getpid(), "another-host.example.com", 1) is False def test_owner_is_stale_dead_process_reclaimed() -> None: assert owner_is_stale(_DEAD_PID, host_name(), 1) is True def test_owner_is_stale_live_process_without_token_held() -> None: assert owner_is_stale(os.getpid(), host_name(), None) is False def test_owner_is_stale_live_process_matching_token_held() -> None: assert owner_is_stale(os.getpid(), host_name(), process_start_token(os.getpid())) is False @_NEEDS_START_TOKEN def test_owner_is_stale_live_process_mismatched_token_reclaimed() -> None: token = process_start_token(os.getpid()) assert token is not None assert owner_is_stale(os.getpid(), host_name(), token + 1) is True def test_owner_is_stale_live_process_unreadable_token_held(mocker: MockerFixture) -> None: # A live PID whose current start token cannot be read is indistinguishable from the recorded owner, so it holds. mocker.patch("filelock._identity.process_start_token", return_value=None) assert owner_is_stale(os.getpid(), host_name(), 987654) is False filelock-3.32.2/tests/test_read_write.py0000644000000000000000000005516113615410400015221 0ustar00from __future__ import annotations import sys import time from multiprocessing import Event, Process, Value, set_start_method from typing import TYPE_CHECKING, Final, Literal, cast import pytest pytest.importorskip("sqlite3") import sqlite3 from filelock import ReadWriteLock, Timeout from tests.capability_marks import SKIP_ON_UNRELIABLE_PROCESS_SYNC from tests.process_helpers import cleanup_processes from tests.read_write_helpers import assert_read_write_lock_state # Bounds how long a spawned process may take to reach the lock, not how fast it must be: an interpreter that starts # slowly under a loaded suite is not a locking failure. Only a genuine hang waits this out. Each test's own timeout # has to outlast the waits it makes, so those are multiples of this rather than bare seconds. _PROCESS_DEADLINE: Final[int] = 30 # Reaping a process that has already been signaled is not a startup wait, and it has to stay under the suite's own # per-test timeout so the guard still reports the hang it was put there for. _REAP_DEADLINE: Final[int] = 5 pytestmark = SKIP_ON_UNRELIABLE_PROCESS_SYNC if sys.implementation.name == "pypy": set_start_method( "spawn", force=True ) # pragma: no cover # exercised only under the pypy fork backend, which runs without coverage if TYPE_CHECKING: from multiprocessing.sharedctypes import Synchronized from multiprocessing.synchronize import Event as EventType from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_read_locks_are_shared(lock_file: str) -> None: read1_acquired = Event() read2_acquired = Event() reader1 = Process(target=acquire_lock, args=(lock_file, "read", read1_acquired)) reader2 = Process(target=acquire_lock, args=(lock_file, "read", read2_acquired)) with cleanup_processes([reader1, reader2]): reader1.start() time.sleep(0.5) # let reader1 acquire before reader2 starts reader2.start() assert read1_acquired.wait(timeout=_PROCESS_DEADLINE), f"First read lock not acquired on {lock_file}" assert read2_acquired.wait(timeout=_PROCESS_DEADLINE), f"Second read lock not acquired on {lock_file}" reader1.join(timeout=_PROCESS_DEADLINE) reader2.join(timeout=_PROCESS_DEADLINE) assert not reader1.is_alive(), "Reader 1 did not exit cleanly" assert not reader2.is_alive(), "Reader 2 did not exit cleanly" @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_write_lock_excludes_other_write_locks(lock_file: str) -> None: write1_acquired = Event() release_write1 = Event() write2_acquired = Event() holder = Process(target=acquire_lock, args=(lock_file, "write", write1_acquired, release_write1)) contender = Process(target=acquire_lock, args=(lock_file, "write", write2_acquired, None, 0.5, True)) with cleanup_processes([holder]): holder.start() assert write1_acquired.wait(timeout=_PROCESS_DEADLINE), "First write lock not acquired" with cleanup_processes([contender]): contender.start() assert not write2_acquired.wait(timeout=1), "Second write lock should not be acquired" release_write1.set() holder.join(timeout=_PROCESS_DEADLINE) assert not holder.is_alive(), "Holder did not exit cleanly" write2_acquired.clear() successor = Process(target=acquire_lock, args=(lock_file, "write", write2_acquired)) with cleanup_processes([successor]): successor.start() assert write2_acquired.wait(timeout=_PROCESS_DEADLINE), ( "Second write lock not acquired after first released" ) successor.join(timeout=_PROCESS_DEADLINE) assert not successor.is_alive(), "Successor did not exit cleanly" @pytest.mark.parametrize( ("holder_mode", "contender_mode"), [ pytest.param("write", "read", id="write-blocks-read"), pytest.param("read", "write", id="read-blocks-write"), ], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_lock_excludes_opposite_mode( lock_file: str, holder_mode: Literal["read", "write"], contender_mode: Literal["read", "write"], ) -> None: holder_acquired = Event() release_holder = Event() contender_acquired = Event() contender_started = Event() holder = Process(target=acquire_lock, args=(lock_file, holder_mode, holder_acquired, release_holder)) contender = Process( target=acquire_lock, args=(lock_file, contender_mode, contender_acquired, None, -1, True, contender_started), ) with cleanup_processes([holder, contender]): holder.start() assert holder_acquired.wait(timeout=_PROCESS_DEADLINE), f"{holder_mode} lock not acquired" contender.start() contender_started.set() time.sleep(2) # confirm the contender stays blocked assert not contender_acquired.is_set(), f"{contender_mode} lock should not be acquired while {holder_mode} held" release_holder.set() holder.join(timeout=_PROCESS_DEADLINE) assert contender_acquired.wait(timeout=_PROCESS_DEADLINE), ( f"{contender_mode} lock not acquired after {holder_mode} released" ) contender.join(timeout=_PROCESS_DEADLINE) assert not contender.is_alive(), "Contender did not exit cleanly" @pytest.mark.timeout(_PROCESS_DEADLINE * 8) def test_write_non_starvation(lock_file: str) -> None: """A writer that joins after the first reader must acquire before the reader chain drains. Seven readers overlap in a chain and the writer contends mid-chain. It should win the lock while readers still hold it, so it is not starved. """ NUM_READERS: Final[int] = 7 chain_forward = [Event() for _ in range(NUM_READERS)] chain_backward = [Event() for _ in range(NUM_READERS)] writer_ready = Event() writer_contending = Event() writer_acquired = Event() release_count = Value("i", 0) readers = [] for idx in range(NUM_READERS): next_reader = chain_forward[idx + 1] if idx < NUM_READERS - 1 else None prev_or_writer = chain_backward[idx - 1] if idx > 0 else writer_ready reader = Process( target=chain_reader, args=(idx, lock_file, release_count, chain_forward[idx], chain_backward[idx], next_reader, prev_or_writer), ) readers.append(reader) # The last reader holds until its own wait expires, since the test only releases the chain once the writer is in, # so the writer has to outwait that hold rather than time out first. writer = Process( target=acquire_lock, args=(lock_file, "write", writer_acquired, None, _PROCESS_DEADLINE * 2, True, writer_ready, writer_contending), ) with cleanup_processes([*readers, writer]): for reader in readers: reader.start() chain_forward[0].set() assert writer_ready.wait(timeout=_PROCESS_DEADLINE), "First reader did not acquire lock" # Count only the releases the writer actually waited through; a slow interpreter start is not starvation. # The contending event says the writer process booted, not that its write intent reached the database, and # only the intent bars new readers. The probe observes that boundary: a non-blocking read acquire times out # exactly once the intent is registered, so the count starts there. Probing before the writer exists pins # the still-pending arc, which the loop alone cannot promise: on a fast host the first in-loop probe # already times out. prober = ReadWriteLock(lock_file, is_singleton=False) probe_deadline = time.monotonic() + _PROCESS_DEADLINE assert _write_intent_pending(prober, probe_deadline) writer.start() assert writer_contending.wait(timeout=_PROCESS_DEADLINE), "Writer process did not reach the lock" # pragma-no-branch: the loop never exhausts in a passing run, since the helper's deadline fires first for _ in range(_PROCESS_DEADLINE * 20): # pragma: no branch if not _write_intent_pending(prober, probe_deadline): # pragma: no branch # exits at the writer's speed break prober.close() with release_count.get_lock(): releases_before_contending = release_count.value assert writer_acquired.wait(timeout=_PROCESS_DEADLINE * 2), "Writer couldn't acquire lock - possible starvation" with release_count.get_lock(): read_releases = release_count.value - releases_before_contending assert read_releases < 3, f"Writer acquired after {read_releases} readers released - this indicates starvation" writer.join(timeout=_PROCESS_DEADLINE) assert not writer.is_alive(), "Writer did not exit cleanly" for event in chain_backward: event.set() for idx, reader in enumerate(readers): reader.join(timeout=_PROCESS_DEADLINE) assert not reader.is_alive(), f"Reader {idx} did not exit cleanly" @pytest.mark.parametrize( "hold_mode", [pytest.param("read", id="upgrade"), pytest.param("write", id="downgrade")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_lock_mode_transition_prohibited(lock_file: str, hold_mode: Literal["read", "write"]) -> None: result = Value("i", -1) worker = Process(target=try_illegal_transition, args=(lock_file, hold_mode, result)) with cleanup_processes([worker]): worker.start() worker.join(timeout=_PROCESS_DEADLINE) assert not worker.is_alive(), "Process did not exit cleanly" assert result.value == 0, f"Illegal {hold_mode} transition was permitted" @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_timeout_behavior(lock_file: str) -> None: write_acquired = Event() release_write = Event() read_acquired = Event() writer = Process(target=acquire_lock, args=(lock_file, "write", write_acquired, release_write)) reader = Process(target=acquire_lock, args=(lock_file, "read", read_acquired, None, 0.5, True)) with cleanup_processes([writer, reader]): writer.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Write lock not acquired" start_time = time.time() reader.start() assert not read_acquired.wait(timeout=1), "Read lock should not be acquired" reader.join(timeout=_PROCESS_DEADLINE) elapsed = time.time() - start_time assert 0.4 <= elapsed <= 10.0, f"Timeout was not respected: {elapsed}s" release_write.set() writer.join(timeout=_PROCESS_DEADLINE) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_non_blocking_behavior(lock_file: str) -> None: write_acquired = Event() release_write = Event() writer = Process(target=acquire_lock, args=(lock_file, "write", write_acquired, release_write)) with cleanup_processes([writer]): writer.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Write lock not acquired" start_time = time.time() with pytest.raises(Timeout): ReadWriteLock(lock_file).acquire_read(blocking=False) elapsed = time.time() - start_time assert elapsed < 0.1, f"Non-blocking took too long: {elapsed}s" release_write.set() writer.join(timeout=_PROCESS_DEADLINE) @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_recursive_lock_acquisition(lock_file: str, mode: Literal["read", "write"]) -> None: success = Value("i", 0) worker = Process(target=recursive_lock, args=(lock_file, mode, success)) with cleanup_processes([worker]): worker.start() worker.join(timeout=_PROCESS_DEADLINE) assert success.value == 1, "Recursive lock acquisition failed" @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_lock_release_on_process_termination(lock_file: str, mode: Literal["read", "write"]) -> None: lock_acquired = Event() crashing = Process(target=acquire_lock_and_crash, args=(lock_file, mode, lock_acquired)) crashing.start() assert lock_acquired.wait(timeout=_PROCESS_DEADLINE), "Lock not acquired by first process" write_acquired = Event() successor = Process(target=acquire_lock, args=(lock_file, "write", write_acquired)) with cleanup_processes([crashing, successor]): time.sleep(0.5) # let the lock settle before killing the holder crashing.terminate() crashing.join(timeout=_REAP_DEADLINE) successor.start() assert write_acquired.wait(timeout=_PROCESS_DEADLINE), "Lock not acquired after process termination" successor.join(timeout=_PROCESS_DEADLINE) assert not successor.is_alive(), "Successor did not exit cleanly" @pytest.mark.parametrize( "mode", [pytest.param("read", id="read"), pytest.param("write", id="write")], ) @pytest.mark.timeout(15) def test_single_lock_reentrant(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file) acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(), acquire(): pass with acquire(): pass @pytest.mark.parametrize( ("first", "second"), [ pytest.param("read", "write", id="read-then-write"), pytest.param("write", "read", id="write-then-read"), ], ) @pytest.mark.timeout(15) def test_sequential_lock_modes( lock_file: str, first: Literal["read", "write"], second: Literal["read", "write"], ) -> None: lock = ReadWriteLock(lock_file) for mode in (first, second): acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(): pass @pytest.mark.parametrize( "path_change", [pytest.param("cwd", id="relative-cwd"), pytest.param("symlink", id="retargeted-symlink")], ) def test_read_write_lock_keeps_constructed_database( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path_change: Literal["cwd", "symlink"] ) -> None: first_directory = tmp_path / "first" second_directory = tmp_path / "second" first_directory.mkdir() second_directory.mkdir() first_database = first_directory / "lock.db" second_database = second_directory / "lock.db" if path_change == "cwd": monkeypatch.chdir(first_directory) lock = ReadWriteLock("lock.db", is_singleton=False) monkeypatch.chdir(second_directory) else: lock_path = tmp_path / "lock.db" try: lock_path.symlink_to(first_database) except OSError as error: # pragma: no cover - platform policy can deny symlink creation pytest.skip(str(error)) lock = ReadWriteLock(lock_path, is_singleton=False) lock_path.unlink() lock_path.symlink_to(second_database) with lock.read_lock(): assert_read_write_lock_state(str(first_database), "write", available=False) assert_read_write_lock_state(str(second_database), "write", available=True) lock.close() @pytest.mark.parametrize( ("held_mode", "probe_mode"), [ pytest.param("read", "write", id="read"), pytest.param("write", "read", id="write"), ], ) @pytest.mark.parametrize( ("after_rollback", "available_after_failure"), [ pytest.param(False, False, id="transaction-open"), pytest.param(True, True, id="transaction-ended"), ], ) def test_release_rollback_failure_reconciles_lock_state( lock_file: str, mocker: MockerFixture, held_mode: Literal["read", "write"], probe_mode: Literal["read", "write"], *, after_rollback: bool, available_after_failure: bool, ) -> None: rollback_error = _patch_rollback_failure(mocker, after_rollback=after_rollback) lock = ReadWriteLock(lock_file, is_singleton=False) (lock.acquire_read if held_mode == "read" else lock.acquire_write)() with pytest.raises(sqlite3.OperationalError, match="rollback failed") as info: lock.release() assert info.value is rollback_error assert_read_write_lock_state(lock_file, probe_mode, available=available_after_failure) if available_after_failure: with pytest.raises(RuntimeError, match="not held"): lock.release() else: lock.release() assert_read_write_lock_state(lock_file, probe_mode, available=True) lock.close() def _patch_rollback_failure(mocker: MockerFixture, *, after_rollback: bool) -> sqlite3.OperationalError: real_connect = sqlite3.connect rollback_error = sqlite3.OperationalError("rollback failed") connection_count = 0 def connect( database: str, *, factory: type[sqlite3.Connection], timeout: float, ) -> sqlite3.Connection: nonlocal connection_count connection_count += 1 real_connection = real_connect( database, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout, ) if connection_count != 2: return real_connection rollback_failed = False def rollback() -> None: nonlocal rollback_failed if not rollback_failed: rollback_failed = True if after_rollback: real_connection.rollback() raise rollback_error real_connection.rollback() connection = mocker.MagicMock( spec_set=type(real_connection), wraps=real_connection, **{"rollback.side_effect": rollback}, ) mocker.patch.object( type(connection), "in_transaction", new_callable=mocker.PropertyMock, create=True, side_effect=lambda: real_connection.in_transaction, ) return cast("sqlite3.Connection", connection) mocker.patch("filelock._read_write._connect", side_effect=connect) return rollback_error @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) def test_acquire_proxy_releases_the_read_write_lock_on_exit(lock_file: str, mode: Literal["read", "write"]) -> None: # acquire_read/acquire_write hand back the shared AcquireReturnProxy; a read/write lock is not a BaseFileLock, so # exiting the proxy must route through the lock's own release() rather than a context-error policy. lock = ReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write with acquire(): assert_read_write_lock_state(lock_file, "write", available=False) assert_read_write_lock_state(lock_file, "write", available=True) def acquire_lock( lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, release_event: EventType | None = None, timeout: float = -1, blocking: bool = True, ready_event: EventType | None = None, contending_event: EventType | None = None, ) -> None: if ready_event: ready_event.wait(timeout=_PROCESS_DEADLINE) lock = ReadWriteLock(lock_file, timeout=timeout, blocking=blocking) if contending_event: contending_event.set() # interpreter start-up is over, so a caller can time the contention itself with lock.read_lock() if mode == "read" else lock.write_lock(): acquired_event.set() if release_event: release_event.wait(timeout=_PROCESS_DEADLINE) else: time.sleep(0.5) # hold briefly to simulate work def test_cleanup_reports_the_failure_that_escaped_before_a_start() -> None: unstarted = Process(target=time.sleep, args=(0,)) def fail_before_start() -> None: with cleanup_processes([unstarted]): msg = "the real failure" raise AssertionError(msg) with pytest.raises(AssertionError, match="the real failure"): fail_before_start() def _write_intent_pending(prober: ReadWriteLock, deadline: float) -> bool: try: prober.acquire_read(blocking=False) except Timeout: return False prober.release() assert time.monotonic() < deadline, "Writer never registered its write intent" time.sleep(0.05) return True def chain_reader( reader_index: int, lock_file: str, release_count: Synchronized[int], start_signal: EventType, release_signal: EventType, next_reader_signal: EventType | None, writer_or_prev_signal: EventType, ) -> None: start_signal.wait(timeout=_PROCESS_DEADLINE) lock = ReadWriteLock(lock_file) with lock.read_lock(): if reader_index > 0: time.sleep(2) # overlap with earlier readers so the writer contends mid-chain if next_reader_signal is not None: next_reader_signal.set() if reader_index == 0: time.sleep(1) # hold briefly before releasing the writer writer_or_prev_signal.set() release_signal.wait(timeout=_PROCESS_DEADLINE) with release_count.get_lock(): release_count.value += 1 def try_illegal_transition(lock_file: str, hold_mode: Literal["read", "write"], result: Synchronized[int]) -> None: lock = ReadWriteLock(lock_file) with lock.read_lock() if hold_mode == "read" else lock.write_lock(): try: if hold_mode == "read": lock.acquire_write() else: lock.acquire_read() except RuntimeError: result.value = 0 def recursive_lock(lock_file: str, mode: Literal["read", "write"], success_flag: Synchronized[int]) -> None: lock = ReadWriteLock(lock_file) acquire = lock.read_lock if mode == "read" else lock.write_lock with acquire(): assert lock._lock_level == 1 assert lock._current_mode == mode with acquire(): assert lock._lock_level == 2 assert lock._current_mode == mode with acquire(): assert lock._lock_level == 3 assert lock._current_mode == mode assert lock._lock_level == 2 assert lock._current_mode == mode assert lock._lock_level == 1 assert lock._current_mode == mode assert lock._lock_level == 0 assert lock._current_mode is None success_flag.value = 1 def acquire_lock_and_crash(lock_file: str, mode: Literal["read", "write"], acquired_event: EventType) -> None: lock = ReadWriteLock(lock_file) # pragma: win32 no cover with lock.read_lock() if mode == "read" else lock.write_lock(): # pragma: win32 no cover acquired_event.set() while True: # pragma: win32 no cover time.sleep(0.1) filelock-3.32.2/tests/test_read_write_fork.py0000644000000000000000000010201713615410400016233 0ustar00from __future__ import annotations import contextlib import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # isolates process-global audit hooks and fork exits import sys import textwrap from typing import TYPE_CHECKING, Final, Literal import pytest from capabilities import CAPABILITIES from filelock import ReadWriteLock from tests.capability_marks import ( NEEDS_AUDIT_EVENTS, NEEDS_COLLECTED_FINALIZATION, NEEDS_FORK, NEEDS_FORK1, ) if TYPE_CHECKING: from pathlib import Path _NEEDS_FD_DIRECTORY: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["fd-directory"], reason="counting open descriptors needs a /dev/fd or /proc/self/fd view" ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_closes_idle_connections(tmp_path: Path) -> None: lock_path = tmp_path / "idle.db" connection_events = 0 def audit_hook( # pragma: no cover - interpreter audit hooks run with tracing disabled event: str, args: tuple[object, ...] ) -> None: nonlocal connection_events if event != "sqlite3.connect": return database = args[0] if isinstance(database, (str, bytes)) and os.fsdecode(database) == str(lock_path): connection_events += 1 sys.addaudithook(audit_hook) lock = ReadWriteLock(lock_path, is_singleton=False) lock.acquire_read() lock.acquire_read() lock.release() lock.release() lock.acquire_write() lock.release() lock_path.unlink() assert connection_events == 3 @_NEEDS_FD_DIRECTORY @NEEDS_COLLECTED_FINALIZATION def test_read_write_lock_dropped_instances_leave_no_descriptors(tmp_path: Path) -> None: # pragma: needs fd-directory result = _run_fork_script(_dropped_instances_script(), [str(tmp_path)], timeout=10) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork @NEEDS_COLLECTED_FINALIZATION def test_async_read_write_lock_allows_finalizer_reentry(tmp_path: Path) -> None: result = _run_fork_script( _reentrant_finalizer_script(), [str(tmp_path / "async.db"), str(tmp_path / "finalizer.db")], timeout=10, ) assert result == (0, "", "") def test_read_write_lock_subclass_has_independent_singletons(tmp_path: Path) -> None: class DerivedReadWriteLock(ReadWriteLock): pass lock_path = tmp_path / "subclass.db" base_lock = ReadWriteLock(lock_path) derived_lock = DerivedReadWriteLock(lock_path) assert (derived_lock is DerivedReadWriteLock(lock_path), derived_lock is not base_lock) == (True, True) @pytest.mark.parametrize( "audit_event", [ pytest.param("sqlite3.connect", marks=NEEDS_AUDIT_EVENTS, id="sqlite-connect"), pytest.param( "ctypes.dlsym", marks=pytest.mark.skipif( sys.implementation.name != "cpython" or sys.version_info >= (3, 12) or sys.platform == "win32", reason="connection escrow uses this audit event only on POSIX CPython before 3.12", ), id="ctypes-dlsym", ), ], ) def test_read_write_lock_rejects_recursive_singleton_construction(tmp_path: Path, audit_event: str) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _recursive_construction_script(), str(tmp_path / "construction.db"), audit_event], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.skipif( sys.implementation.name != "cpython" or sys.version_info >= (3, 12), reason="connection escrow uses CPython reference functions before 3.12", ) def test_read_write_lock_escrow_ignores_shared_ctypes_signatures(tmp_path: Path) -> None: # pragma: <3.12 cover result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _ctypes_signature_script(), str(tmp_path / "ctypes.db")], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.parametrize( "operation", [ pytest.param("acquire", id="acquire"), pytest.param("release", id="release"), pytest.param("close", id="close"), ], ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_rejects_same_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["acquire", "release", "close"] ) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _recursive_acquisition_script(), str(tmp_path / "acquisition.db"), operation], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @pytest.mark.parametrize( "operation", [ pytest.param("release", id="release"), pytest.param("close", id="close"), ], ) @NEEDS_AUDIT_EVENTS def test_read_write_lock_serializes_other_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["release", "close"] ) -> None: result = subprocess.run( # executes this test's fixed interpreter and source [sys.executable, "-c", _concurrent_operation_script(), str(tmp_path / "concurrent.db"), operation], capture_output=True, check=False, text=True, timeout=10, ) assert (result.returncode, result.stderr) == (0, "") @NEEDS_FORK # pragma: needs fork @pytest.mark.parametrize( ("mode", "fork_name", "reject_audit_hook"), [ pytest.param("read", "fork", False, id="read-fork"), pytest.param("write", "fork", False, id="write-fork"), pytest.param("write", "fork", True, id="write-fork-rejected-audit-hook"), pytest.param( "read", "fork1", False, marks=NEEDS_FORK1, id="read-fork1", ), pytest.param( "write", "fork1", False, marks=NEEDS_FORK1, id="write-fork1", ), ], ) def test_read_write_lock_survives_normal_fork_child_exit( tmp_path: Path, mode: Literal["read", "write"], fork_name: Literal["fork", "fork1"], reject_audit_hook: bool, ) -> None: result = _run_fork_script( _fork_script(), [ str(tmp_path / "fork.db"), mode, fork_name, "reject" if reject_audit_hook else "allow", ], timeout=15, ) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork def test_read_write_lock_fork_waits_for_sqlite_operation(tmp_path: Path) -> None: result = _run_fork_script(_fork_during_sqlite_script(), [str(tmp_path / "fork-gate.db")], timeout=15) assert result == (0, "", "") @pytest.mark.skipif( sys.implementation.name != "cpython" or not hasattr(os, "fork"), reason="requires CPython profile events and os.fork", ) @pytest.mark.parametrize( "boundary", [ pytest.param("executescript", id="execute"), pytest.param("rollback", id="rollback"), pytest.param("close", id="close"), pytest.param("connection-return", id="connection-return"), ], ) def test_read_write_lock_fork_at_sqlite_boundary_exits_child( # pragma: needs fork tmp_path: Path, boundary: Literal["executescript", "rollback", "close", "connection-return"] ) -> None: result = _run_fork_script( _fork_at_sqlite_boundary_script(), [str(tmp_path / "fork-boundary.db"), boundary], timeout=10, ) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork def test_read_write_lock_idle_fork_handles_fresh_child_lock(tmp_path: Path) -> None: result = _run_fork_script(_idle_fork_script(), [str(tmp_path / "idle-fork.db")], timeout=10) assert result == (0, "", "") @pytest.mark.skipif(sys.implementation.name != "pypy" or not hasattr(os, "fork"), reason="requires fork on PyPy") def test_read_write_lock_pypy_child_allows_without_known_sqlite_use(tmp_path: Path) -> None: # pragma: pypy cover result = _run_fork_script( _pypy_fork_script(), [str(tmp_path / "child.db")], timeout=10, ) assert result == (0, "", "") @pytest.mark.skipif(sys.implementation.name != "pypy" or not hasattr(os, "fork"), reason="requires fork on PyPy") def test_read_write_lock_pypy_child_rejects_after_external_sqlite_use(tmp_path: Path) -> None: # pragma: pypy cover result = _run_fork_script( _pypy_external_sqlite_script(), [str(tmp_path / "external.db"), str(tmp_path / "child.db")], timeout=10, ) assert result == (0, "", "") def test_fork_script_terminates_timed_out_process_group() -> None: with pytest.raises(AssertionError, match="fork script exceeded"): _run_fork_script("import time; time.sleep(10)", [], timeout=0.01) @NEEDS_FORK # pragma: needs fork def test_read_write_lock_subclass_cache_resets_after_fork(tmp_path: Path) -> None: result = _run_fork_script(_subclass_fork_script(), [str(tmp_path / "subclass-fork.db")], timeout=10) assert result == (0, "", "") @NEEDS_FORK # pragma: needs fork @pytest.mark.parametrize("held", [pytest.param(False, id="idle"), pytest.param(True, id="held")]) def test_async_read_write_lock_fork_behavior(tmp_path: Path, held: bool) -> None: result = _run_fork_script( _async_fork_script(), [str(tmp_path / "async-fork.db"), "held" if held else "idle"], timeout=15, ) assert result == (0, "", "") def _run_fork_script(script: str, arguments: list[str], *, timeout: float) -> tuple[int, str, str]: process = subprocess.Popen( # executes this test's fixed interpreter and source [sys.executable, "-c", script, *arguments], start_new_session=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) try: process_output, process_error = process.communicate(timeout=timeout) except subprocess.TimeoutExpired as error: with contextlib.suppress(ProcessLookupError): if sys.platform == "win32": # pragma: win32 cover process.kill() else: # pragma: win32 no cover os.killpg(process.pid, signal.SIGKILL) process_output, process_error = process.communicate() msg = f"fork script exceeded {timeout} seconds: stdout={process_output!r}, stderr={process_error!r}" raise AssertionError(msg) from error assert process.returncode is not None # pragma: needs fork return process.returncode, process_output, process_error # pragma: needs fork def _dropped_instances_script() -> str: # pragma: needs fd-directory return textwrap.dedent( """ from __future__ import annotations import gc import sys from pathlib import Path from filelock import ReadWriteLock root = Path(sys.argv[1]) descriptor_path = Path("/dev/fd") if Path("/dev/fd").is_dir() else Path("/proc/self/fd") gc.collect() initial_count = sum(1 for _ in descriptor_path.iterdir()) for index in range(100): lock = ReadWriteLock(root / f"dropped-{index}.db", is_singleton=False) lock.acquire_write() del lock gc.collect() assert sum(1 for _ in descriptor_path.iterdir()) == initial_count with ReadWriteLock(root / "dropped-0.db", is_singleton=False).read_lock(): pass """ ) def _reentrant_finalizer_script() -> str: # pragma: needs fork return textwrap.dedent( """ from __future__ import annotations import asyncio import gc import sys import weakref from threading import Condition, Event from types import CodeType, FrameType from typing import Final from filelock import AsyncReadWriteLock, ReadWriteLock class _FinalizerTarget: cycle: _FinalizerTarget _FINALIZED: Final[Event] = Event() def _close_lock() -> None: ReadWriteLock(sys.argv[2], is_singleton=False).close() _FINALIZED.set() _TARGET = _FinalizerTarget() _TARGET.cycle = _TARGET weakref.finalize(_TARGET, _close_lock) del _TARGET _NOTIFY_CODE: Final[CodeType] = Condition.notify_all.__code__ def _collect_at_notify(frame: FrameType, _event: str, _argument: None) -> None: if frame.f_code is _NOTIFY_CODE: sys.settrace(None) gc.collect() sys.settrace(_collect_at_notify) _LOCK: Final[AsyncReadWriteLock] = AsyncReadWriteLock(sys.argv[1], is_singleton=False) sys.settrace(None) assert _FINALIZED.is_set() asyncio.run(_LOCK.close()) """ ) def _recursive_construction_script() -> str: return textwrap.dedent( """ from __future__ import annotations import sys from filelock import ReadWriteLock lock_path, audit_event = sys.argv[1:] armed = True def audit_hook(event: str, _args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. if armed and event == audit_event: ReadWriteLock(lock_path) sys.addaudithook(audit_hook) try: ReadWriteLock(lock_path) except RuntimeError as error: assert str(error) == f"Singleton lock construction is already active for {lock_path}" else: raise AssertionError("recursive singleton construction succeeded") armed = False with ReadWriteLock(lock_path).read_lock(): pass """ ) def _recursive_acquisition_script() -> str: return textwrap.dedent( """ from __future__ import annotations import sys from filelock import ReadWriteLock lock_path, operation = sys.argv[1:] lock = ReadWriteLock(lock_path) armed = True def audit_hook(event: str, _args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. if armed and event == "sqlite3.connect": if operation == "acquire": lock.acquire_read() elif operation == "release": lock.release() else: lock.close() sys.addaudithook(audit_hook) try: lock.acquire_read() except RuntimeError as error: assert str(error) == ( f"Cannot {operation} ReadWriteLock on {lock_path} while acquisition is active in this thread" ) else: raise AssertionError(f"recursive {operation} succeeded") armed = False with lock.read_lock(): pass """ ) def _ctypes_signature_script() -> str: # pragma: <3.12 cover return textwrap.dedent( """ from __future__ import annotations import ctypes import sys ctypes.pythonapi.Py_IncRef.argtypes = (ctypes.c_void_p,) from filelock import ReadWriteLock lock = ReadWriteLock(sys.argv[1], is_singleton=False) lock.acquire_read() ctypes.pythonapi.Py_DecRef.argtypes = (ctypes.c_void_p,) lock.release() with ReadWriteLock(sys.argv[1], is_singleton=False).write_lock(): pass """ ) def _concurrent_operation_script() -> str: return textwrap.dedent( """ from __future__ import annotations import queue import sqlite3 import sys import threading from filelock import ReadWriteLock lock_path, operation = sys.argv[1:] lock = ReadWriteLock(lock_path) acquisition_inside_connect = threading.Event() continue_acquisition = threading.Event() acquire_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() operation_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() armed = True def audit_hook(event: str, _args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. if armed and event == "sqlite3.connect": acquisition_inside_connect.set() assert continue_acquisition.wait(5) def acquire() -> None: try: lock.acquire_write() except BaseException as error: acquire_errors.put(error) def operate() -> None: try: getattr(lock, operation)() except BaseException as error: operation_errors.put(error) sys.addaudithook(audit_hook) acquire_thread = threading.Thread(target=acquire) acquire_thread.start() assert acquisition_inside_connect.wait(5) operation_thread = threading.Thread(target=operate) operation_thread.start() operation_thread.join(0.1) assert operation_thread.is_alive() armed = False continue_acquisition.set() acquire_thread.join(5) operation_thread.join(5) assert not acquire_thread.is_alive() assert not operation_thread.is_alive() assert acquire_errors.empty() assert operation_errors.empty() if operation == "release": with lock.write_lock(): pass else: try: lock.acquire_read() except sqlite3.ProgrammingError as error: assert str(error) == "Cannot operate on a closed database." else: raise AssertionError("concurrent acquisition reopened a closed lock") """ ) def _fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import gc import os import sqlite3 import sys lock_path, mode, fork_name, audit_hook_mode = sys.argv[1:] if audit_hook_mode == "reject": def reject_filelock_audit_hook(event: str, _args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. if event == "sys.addaudithook": raise RuntimeError("audit hook registration rejected") sys.addaudithook(reject_filelock_audit_hook) from filelock import ReadWriteLock, Timeout lock = ReadWriteLock(lock_path, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write proxy = acquire() parent_to_child_read, parent_to_child_write = os.pipe() child_to_parent_read, child_to_parent_write = os.pipe() child_pid = getattr(os, fork_name)() if child_pid == 0: os.close(parent_to_child_write) os.close(child_to_parent_read) try: with proxy: pass try: lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited acquisition succeeded") lock.release() lock.close() del proxy del lock gc.collect() sqlite_connects = 0 def count_sqlite_connects(event: str, _args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. global sqlite_connects if event == "sqlite3.connect": sqlite_connects += 1 sys.addaudithook(count_sqlite_connects) try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: expected = ( "ReadWriteLock is unavailable in a PyPy fork child" if sys.implementation.name == "pypy" else "was active across fork(); exec or exit" ) assert expected in str(error) else: raise AssertionError("child reopened a database active during fork") assert sqlite_connects == 0 os.write(child_to_parent_write, b"1") assert os.read(parent_to_child_read, 1) == b"1" except BaseException: import traceback traceback.print_exc() sys.exit(1) sys.exit(0) os.close(parent_to_child_read) os.close(child_to_parent_write) assert os.read(child_to_parent_read, 1) == b"1" contender = ReadWriteLock(lock_path, is_singleton=False) conflicting_acquire = contender.acquire_write if mode == "read" else contender.acquire_read try: conflicting_acquire(blocking=False) except Timeout: pass else: raise AssertionError("child exit released the parent's lock") lock.release() os.write(parent_to_child_write, b"1") _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 with sqlite3.connect(lock_path) as connection: assert connection.execute("PRAGMA integrity_check").fetchone() == ("ok",) """ ) def _fork_during_sqlite_script() -> str: # pragma: needs fork return textwrap.dedent( r""" from __future__ import annotations import os import queue import sqlite3 import sys import threading import time import warnings from filelock import ReadWriteLock warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*fork\(\).*") lock_path = sys.argv[1] waiter = ReadWriteLock(lock_path, is_singleton=False) holder = sqlite3.connect(lock_path, check_same_thread=False) holder.executescript("PRAGMA journal_mode=MEMORY; BEGIN EXCLUSIVE TRANSACTION;").close() waiter_inside_connect = threading.Event() continue_connect = threading.Event() acquisition_errors: queue.SimpleQueue[BaseException] = queue.SimpleQueue() armed = True def audit_hook(event: str, args: tuple[object, ...]) -> None: # CPython supplies heterogeneous values for process-wide audit events. if not armed or event != "sqlite3.connect": return database = args[0] if ( isinstance(database, (str, bytes)) and os.fsdecode(database) == lock_path ): waiter_inside_connect.set() assert continue_connect.wait(5) def acquire() -> None: try: with waiter.read_lock(timeout=5): pass except BaseException as error: acquisition_errors.put(error) def release_holder() -> None: threading.Event().wait(0.2) continue_connect.set() holder.rollback() holder.close() sys.addaudithook(audit_hook) waiter_thread = threading.Thread(target=acquire) waiter_thread.start() assert waiter_inside_connect.wait(5) release_thread = threading.Thread(target=release_holder) release_thread.start() start = time.monotonic() child_pid = os.fork() elapsed = time.monotonic() - start if child_pid == 0: sys.exit(0) armed = False _, status = os.waitpid(child_pid, 0) waiter_thread.join(5) release_thread.join(5) assert os.waitstatus_to_exitcode(status) == 0 assert elapsed >= 0.1 assert not waiter_thread.is_alive() assert not release_thread.is_alive() if not acquisition_errors.empty(): raise acquisition_errors.get() """ ) def _fork_at_sqlite_boundary_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sqlite3 import sys from types import FrameType from typing import Protocol class ProfiledCall(Protocol): @property def __name__(self) -> str: ... def reject_filelock_audit_hook(event: str, _args: tuple[object, ...]) -> None: # CPython audit events have heterogeneous interpreter-owned payloads. if event == "sys.addaudithook": raise RuntimeError("audit hook registration rejected") sys.addaudithook(reject_filelock_audit_hook) from filelock import ReadWriteLock lock_path, boundary = sys.argv[1:] child_pids: list[int] = [] armed = True def profile( _frame: FrameType, event: str, value: ProfiledCall | sqlite3.Connection | None, ) -> None: global armed connection_returned = boundary == "connection-return" and event == "return" and isinstance( value, sqlite3.Connection ) sqlite_called = ( boundary != "connection-return" and event == "c_call" and value is not None and value.__name__ == boundary ) if armed and (connection_returned or sqlite_called): armed = False child_pids.append(os.fork()) sys.setprofile(profile) with ReadWriteLock(lock_path, is_singleton=False).write_lock(): pass sys.setprofile(None) assert len(child_pids) == 1 _, status = os.waitpid(child_pids[0], 0) assert os.waitstatus_to_exitcode(status) == 70 """ ) def _idle_fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock lock_path = sys.argv[1] inherited_lock = ReadWriteLock(lock_path, is_singleton=False) child_pid = os.fork() if child_pid == 0: try: inherited_lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited idle lock acquired") if sys.implementation.name == "pypy": try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child opened SQLite after fork") else: with ReadWriteLock(lock_path, is_singleton=False).write_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _pypy_fork_script() -> str: # pragma: pypy cover return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock child_path = sys.argv[1] child_pid = os.fork() if child_pid == 0: with ReadWriteLock(child_path, is_singleton=False).read_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _pypy_external_sqlite_script() -> str: # pragma: pypy cover return textwrap.dedent( """ from __future__ import annotations import os import sqlite3 import sys from filelock import ReadWriteLock external_path, child_path = sys.argv[1:] sqlite3.connect(external_path).close() child_pid = os.fork() if child_pid == 0: try: ReadWriteLock(child_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child opened SQLite after parent use") sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _subclass_fork_script() -> str: return textwrap.dedent( """ from __future__ import annotations import os import sys from filelock import ReadWriteLock class DerivedReadWriteLock(ReadWriteLock): pass lock_path = sys.argv[1] inherited_lock = DerivedReadWriteLock(lock_path) child_pid = os.fork() if child_pid == 0: if sys.implementation.name == "pypy": try: DerivedReadWriteLock(lock_path) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child constructed a lock after fork") else: child_lock = DerivedReadWriteLock(lock_path) assert child_lock is not inherited_lock with child_lock.read_lock(): pass sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 """ ) def _async_fork_script() -> str: return textwrap.dedent( r""" from __future__ import annotations import asyncio import gc import os import sqlite3 import sys import warnings from filelock import AsyncAcquireReadWriteReturnProxy, AsyncReadWriteLock, ReadWriteLock, Timeout warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*fork\(\).*") lock_path, state = sys.argv[1:] async def set_up_parent() -> tuple[AsyncReadWriteLock, AsyncAcquireReadWriteReturnProxy | None]: parent_lock = AsyncReadWriteLock(lock_path, is_singleton=False) parent_proxy = await parent_lock.acquire_write() if state == "held" else None return parent_lock, parent_proxy lock, proxy = asyncio.run(set_up_parent()) child_pid = os.fork() if child_pid == 0: async def check_child() -> None: if proxy is not None: async with proxy: pass try: await lock.acquire_read() except RuntimeError as error: assert "was invalidated by fork()" in str(error) else: raise AssertionError("inherited async lock acquired") await lock.release() await lock.close() if sys.implementation.name == "pypy": try: AsyncReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: assert str(error) == ( "ReadWriteLock is unavailable in a PyPy fork child; exec or exit before using it" ) else: raise AssertionError("PyPy child constructed an async lock after fork") elif state == "idle": fresh_lock = AsyncReadWriteLock(lock_path, is_singleton=False) async with fresh_lock.write_lock(): pass await fresh_lock.close() else: try: ReadWriteLock(lock_path, is_singleton=False) except RuntimeError as error: expected = ( "ReadWriteLock is unavailable in a PyPy fork child" if sys.implementation.name == "pypy" else "was active across fork(); exec or exit" ) assert expected in str(error) else: raise AssertionError("active async database reopened in child") asyncio.run(check_child()) del lock gc.collect() sys.exit(0) _, status = os.waitpid(child_pid, 0) assert os.waitstatus_to_exitcode(status) == 0 if state == "held": contender = ReadWriteLock(lock_path, is_singleton=False) try: contender.acquire_read(blocking=False) except Timeout: pass else: raise AssertionError("async child cleanup released the parent's lock") asyncio.run(lock.release()) asyncio.run(lock.close()) with sqlite3.connect(lock_path) as connection: assert connection.execute("PRAGMA integrity_check").fetchone() == ("ok",) """ ) filelock-3.32.2/tests/test_read_write_unit.py0000644000000000000000000005370413615410400016261 0ustar00from __future__ import annotations import os import threading from typing import TYPE_CHECKING, Literal import pytest from tests.capability_marks import NEEDS_GENERATOR_EXCEPTION_CONTEXT pytest.importorskip("sqlite3") import sqlite3 from pathlib import Path from filelock import Timeout from filelock._read_write import ( _FORKED_DATABASES, _MAX_SQLITE_TIMEOUT_MS, ReadWriteLock, _ForkedDatabaseRegistry, _track_sqlite_use, timeout_for_sqlite, ) if TYPE_CHECKING: from collections.abc import Generator from pytest_mock import MockerFixture @pytest.fixture(autouse=True) def _clear_singleton_cache() -> Generator[None]: ReadWriteLock._instances.clear() yield for instance in list(ReadWriteLock._instances.valuerefs()): if (lock := instance()) is not None: # pragma: no cover # cache is normally emptied before teardown lock.close() ReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") def test_timeout_for_sqlite_non_blocking() -> None: assert timeout_for_sqlite(10.0, blocking=False, already_waited=0.0) == 0 def test_timeout_for_sqlite_infinite_timeout() -> None: assert timeout_for_sqlite(-1, blocking=True, already_waited=0.0) == _MAX_SQLITE_TIMEOUT_MS def test_timeout_for_sqlite_negative_timeout_raises() -> None: with pytest.raises(ValueError, match="timeout must be a non-negative number or -1"): timeout_for_sqlite(-2, blocking=True, already_waited=0.0) def test_timeout_for_sqlite_positive_timeout_subtracts_waited() -> None: assert timeout_for_sqlite(5.0, blocking=True, already_waited=2.0) == 3000 def test_timeout_for_sqlite_waited_exceeds_timeout() -> None: assert timeout_for_sqlite(1.0, blocking=True, already_waited=2.0) == 0 def test_timeout_for_sqlite_zero_timeout() -> None: assert timeout_for_sqlite(0.0, blocking=True, already_waited=0.0) == 0 def test_timeout_for_sqlite_huge_timeout_clamped() -> None: assert timeout_for_sqlite(3_000_000.0, blocking=True, already_waited=0.0) == _MAX_SQLITE_TIMEOUT_MS def test_singleton_returns_same_instance(lock_file: str) -> None: lock1 = ReadWriteLock(lock_file) lock2 = ReadWriteLock(lock_file) assert lock1 is lock2 def test_singleton_different_files(tmp_path: Path) -> None: lock1 = ReadWriteLock(str(tmp_path / "a.db")) lock2 = ReadWriteLock(str(tmp_path / "b.db")) assert lock1 is not lock2 def test_singleton_rejects_different_timeout(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=1.0) with pytest.raises(ValueError, match="Singleton lock created with"): ReadWriteLock(lock_file, timeout=2.0) del lock def test_singleton_rejects_different_blocking(lock_file: str) -> None: lock = ReadWriteLock(lock_file, blocking=True) with pytest.raises(ValueError, match="Singleton lock created with"): ReadWriteLock(lock_file, blocking=False) del lock def test_non_singleton_creates_new_instance(lock_file: str) -> None: lock1 = ReadWriteLock(lock_file, is_singleton=False) lock2 = ReadWriteLock(lock_file, is_singleton=False) assert lock1 is not lock2 def test_init_sets_attributes(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=5.0, blocking=False, is_singleton=False) assert lock.lock_file == lock_file assert lock.timeout == pytest.approx(5.0) assert lock.blocking is False assert lock._lock_level == 0 assert lock._current_mode is None assert lock._write_thread_id is None assert Path(lock_file).is_file() def test_acquire_release_read(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) proxy = lock.acquire_read() assert lock._lock_level == 1 assert lock._current_mode == "read" lock.release() assert lock._lock_level == 0 assert lock._current_mode is None assert isinstance(proxy, object) def test_acquire_release_write(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() assert lock._lock_level == 1 assert lock._current_mode == "write" assert lock._write_thread_id == threading.get_ident() lock.release() assert lock._lock_level == 0 assert lock._current_mode is None assert lock._write_thread_id is None @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_reentrant_lock(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) acquire = lock.acquire_read if mode == "read" else lock.acquire_write acquire() acquire() assert lock._lock_level == 2 lock.release() assert lock._lock_level == 1 assert lock._current_mode == mode lock.release() assert lock._lock_level == 0 def test_upgrade_read_to_write_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() with pytest.raises(RuntimeError, match=r"already holding a read lock.*upgrade not allowed"): lock.acquire_write() lock.release() def test_downgrade_write_to_read_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() with pytest.raises(RuntimeError, match=r"already holding a write lock.*downgrade not allowed"): lock.acquire_read() lock.release() def test_release_unheld_lock_raises(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with pytest.raises(RuntimeError, match="not held"): lock.release() def test_release_force_unheld_is_noop(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.release(force=True) def test_release_force_resets_level(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() lock.acquire_write() assert lock._lock_level == 2 lock.release(force=True) assert lock._lock_level == 0 assert lock._current_mode is None def test_release_force_closes_lingering_connection_at_zero_level(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() lock._lock_level = 0 lock.release(force=True) assert lock._con is None assert lock._lock_level == 0 @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_lock_context_manager(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock() if mode == "read" else lock.write_lock(): assert lock._lock_level == 1 assert lock._current_mode == mode assert lock._lock_level == 0 assert lock._current_mode is None @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_lock_uses_instance_defaults(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, timeout=3.0, blocking=True, is_singleton=False) with lock.read_lock() if mode == "read" else lock.write_lock(): assert lock._current_mode == mode def test_read_lock_custom_timeout(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock(timeout=5.0): assert lock._current_mode == "read" def test_write_lock_custom_blocking(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.write_lock(blocking=True): assert lock._current_mode == "write" def test_read_lock_context_manager_overrides_defaults(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) with lock.read_lock(timeout=5.0, blocking=True): assert lock._current_mode == "read" def test_close_releases_lock_and_closes_connection(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() assert lock._lock_level == 1 lock.close() assert lock._lock_level == 0 with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): lock.acquire_read() def test_close_on_unheld_lock_closes_connection(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.close() with pytest.raises(sqlite3.ProgrammingError, match="Cannot operate on a closed database"): lock.acquire_read() def test_write_lock_reentrant_from_different_thread_prohibited(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() error: RuntimeError | None = None def try_reenter() -> None: nonlocal error try: lock.acquire_write() except RuntimeError as exc: error = exc thread = threading.Thread(target=try_reenter) thread.start() thread.join(timeout=5) lock.release() assert error is not None assert "while it is held by thread" in str(error) @pytest.mark.parametrize( ("first", "second"), [ pytest.param("read", "write", id="read-then-write"), pytest.param("write", "read", id="write-then-read"), ], ) def test_sequential_mode_switch(lock_file: str, first: str, second: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock() if first == "read" else lock.write_lock(): pass with lock.read_lock() if second == "read" else lock.write_lock(): pass def test_non_blocking_read_fails_when_write_held(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_write() -> None: with holder_lock.write_lock(): acquired.set() threading.Event().wait(timeout=2) thread = threading.Thread(target=hold_write) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_read(blocking=False) thread.join(timeout=5) def test_non_blocking_write_fails_when_read_held(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_read() -> None: with holder_lock.read_lock(): acquired.set() threading.Event().wait(timeout=2) thread = threading.Thread(target=hold_read) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_write(blocking=False) thread.join(timeout=5) def test_timeout_read_expires(lock_file: str) -> None: holder_lock = ReadWriteLock(lock_file, is_singleton=False) contender_lock = ReadWriteLock(lock_file, is_singleton=False) acquired = threading.Event() def hold_write() -> None: with holder_lock.write_lock(): acquired.set() threading.Event().wait(timeout=3) thread = threading.Thread(target=hold_write) thread.start() acquired.wait(timeout=5) with pytest.raises(Timeout): contender_lock.acquire_read(timeout=0.2) thread.join(timeout=5) def test_nested_read_context_managers(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.read_lock(): assert lock._lock_level == 1 with lock.read_lock(): assert lock._lock_level == 2 with lock.read_lock(): assert lock._lock_level == 3 assert lock._lock_level == 2 assert lock._lock_level == 1 assert lock._lock_level == 0 def test_nested_write_context_managers(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) with lock.write_lock(): assert lock._lock_level == 1 with lock.write_lock(): assert lock._lock_level == 2 assert lock._lock_level == 1 assert lock._lock_level == 0 @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_non_blocking_transaction_lock_timeout(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False) finally: lock._transaction_lock.release() @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_non_blocking_with_timeout_no_value_error(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(blocking=False, timeout=5.0) finally: lock._transaction_lock.release() @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_finite_timeout_transaction_lock(lock_file: str, mode: Literal["read", "write"]) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock._transaction_lock.acquire() try: with pytest.raises(Timeout): (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=0.1) finally: lock._transaction_lock.release() def test_double_check_read_reentrance(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = "read" return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock proxy = lock.acquire_read() assert lock._lock_level == 2 assert proxy is not None lock._lock_level = 0 lock._current_mode = None lock.release(force=True) def test_double_check_write_reentrance(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = "write" lock._write_thread_id = threading.get_ident() return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock proxy = lock.acquire_write() assert lock._lock_level == 2 assert proxy is not None lock._lock_level = 0 lock._current_mode = None lock._write_thread_id = None lock.release(force=True) @pytest.mark.parametrize( ("acquire_mode", "conflicting_mode", "match"), [ pytest.param("read", "write", "downgrade not allowed", id="read-blocked-by-write"), pytest.param("write", "read", "upgrade not allowed", id="write-blocked-by-read"), ], ) def test_double_check_wrong_mode_raises( lock_file: str, mocker: MockerFixture, acquire_mode: Literal["read", "write"], conflicting_mode: Literal["read", "write"], match: str, ) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) real_lock = lock._transaction_lock mock_lock = mocker.MagicMock() def fake_acquire(blocking: bool = True, timeout: float = -1) -> bool: lock._lock_level = 1 lock._current_mode = conflicting_mode return real_lock.acquire(blocking, timeout) mock_lock.acquire = fake_acquire mock_lock.release = real_lock.release lock._transaction_lock = mock_lock with pytest.raises(RuntimeError, match=match): (lock.acquire_read if acquire_mode == "read" else lock.acquire_write)() lock._lock_level = 0 lock._current_mode = None def test_write_lock_context_manager_overrides_defaults(lock_file: str) -> None: lock = ReadWriteLock(lock_file, timeout=10.0, blocking=False, is_singleton=False) with lock.write_lock(timeout=5.0, blocking=True): assert lock._current_mode == "write" @pytest.mark.parametrize( "mode", [ pytest.param("read", id="read"), pytest.param("write", id="write"), ], ) def test_busy_timeout_recomputed_after_journal_mode( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: counter = iter([0.0, 0.0, 0.0, 0.5]) mocker.patch("filelock._read_write.time.perf_counter", side_effect=counter) lock = ReadWriteLock(lock_file, is_singleton=False) (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=2.0) lock.release() def test_acquire_converts_connect_lock_error_to_timeout(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) mocker.patch( "filelock._read_write._connect", side_effect=sqlite3.OperationalError("database is locked"), ) with pytest.raises(Timeout): lock.acquire_read(timeout=0.1) def test_get_lock_returns_the_singleton(lock_file: str) -> None: lock = ReadWriteLock.get_lock(lock_file) assert isinstance(lock, ReadWriteLock) assert lock is ReadWriteLock(lock_file) def test_note_sqlite_use_records_use_on_pypy(mocker: MockerFixture) -> None: mocker.patch("filelock._read_write._IS_PYPY", True) registry = _ForkedDatabaseRegistry() registry.note_sqlite_use() assert registry._sqlite_used is True @pytest.mark.parametrize("identity", [None, (1, 2)], ids=["no-identity", "with-identity"]) def test_poison_after_fork_records_path_and_identity(identity: tuple[int, int] | None) -> None: registry = _ForkedDatabaseRegistry() path = Path("poisoned.db") registry.poison_after_fork(path, identity) assert path in registry._paths assert (identity in registry._identities) is (identity is not None) def test_track_sqlite_use_forwards_connect_event(mocker: MockerFixture) -> None: note = mocker.patch.object(_FORKED_DATABASES, "note_sqlite_use") _track_sqlite_use("sqlite3.connect", ()) note.assert_called_once_with() def test_track_sqlite_use_ignores_other_events(mocker: MockerFixture) -> None: note = mocker.patch.object(_FORKED_DATABASES, "note_sqlite_use") _track_sqlite_use("open", ()) note.assert_not_called() def test_meta_resets_class_state_after_pid_change(lock_file: str) -> None: class _Sub(ReadWriteLock): pass _Sub._instances_pid = -1 lock = _Sub(lock_file) try: assert isinstance(lock, _Sub) assert _Sub._instances_pid == os.getpid() finally: lock.close() def test_meta_raises_when_pid_changes_during_singleton_construction(lock_file: str, mocker: MockerFixture) -> None: class _Sub(ReadWriteLock): pass pid_box = [os.getpid()] mocker.patch("filelock._read_write._GETPID", side_effect=lambda: pid_box[0]) original_init = _Sub.__init__ def _init_then_fork( self: _Sub, lock_file: str | os.PathLike[str], timeout: float = -1, *, blocking: bool = True, is_singleton: bool = True, ) -> None: original_init(self, lock_file, timeout, blocking=blocking, is_singleton=is_singleton) pid_box[0] += 1 mocker.patch.object(_Sub, "__init__", _init_then_fork) with pytest.raises(RuntimeError, match="construction cannot continue after fork"): _Sub(lock_file) @NEEDS_GENERATOR_EXCEPTION_CONTEXT def test_finish_connection_chains_rollback_then_close_error(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) con = mocker.MagicMock() type(con).in_transaction = mocker.PropertyMock(side_effect=[True, False]) con.rollback.side_effect = sqlite3.OperationalError("rollback boom") con.close.side_effect = sqlite3.OperationalError("close boom") lock._con = con lock._connection_transaction_released = False with pytest.raises(sqlite3.OperationalError, match="close boom") as excinfo: lock._finish_connection() assert isinstance(excinfo.value.__context__, sqlite3.OperationalError) assert "rollback boom" in str(excinfo.value.__context__) con.rollback.assert_called_once_with() con.close.assert_called_once_with() def test_finish_connection_skips_rollback_when_already_released(lock_file: str) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_write() con = lock._con assert con is not None lock._connection_transaction_released = True lock._finish_connection() assert lock._con is None with pytest.raises(sqlite3.ProgrammingError, match="closed database"): con.execute("SELECT 1") def test_dropping_a_lock_closes_its_connection(lock_file: str) -> None: # Nothing else closes a lock dropped while still holding one, so the connection would outlive it. lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() connection = lock._con assert connection is not None lock.__del__() # ruff:ignore[unnecessary-dunder-call] # a finalizer test cannot ride on collection timing with pytest.raises(sqlite3.ProgrammingError): connection.execute("select 1") def test_connection_close_from_another_process_leaves_it_open(lock_file: str, mocker: MockerFixture) -> None: # A child inherits the parent's connection, and closing it there would take the parent's down with it. lock = ReadWriteLock(lock_file, is_singleton=False) lock.acquire_read() connection = lock._con assert connection is not None mocker.patch("filelock._read_write._GETPID", return_value=-1) connection.close() mocker.stopall() assert connection.execute("select 1").fetchone() == (1,) lock.release() filelock-3.32.2/tests/test_soft_lease.py0000644000000000000000000004451013615410400015214 0ustar00from __future__ import annotations import itertools import os import socket import time from contextlib import suppress from errno import EIO, ENOENT from threading import Thread from types import SimpleNamespace from typing import TYPE_CHECKING, cast import pytest from filelock import ( FileLock, LeaseCompromise, LeaseSettingsMismatch, SoftFileLease, StrictSoftFileLock, Timeout, ) from tests.capability_marks import NEEDS_UNLINK_OPEN_FILE if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from pytest_mock import MockerFixture #: Short enough to keep the suite quick, long enough that a loaded runner still refreshes twice before expiry. _DURATION: float = 0.9 _HEARTBEAT: float = 0.1 #: Taking a claim from a live holder means removing its marker while the holder still has it open. Where that is #: refused, a lease only reclaims once the holder exits and its handle closes. @pytest.fixture def marker(tmp_path: Path) -> Path: return tmp_path / "a.lock" def _lease( marker: Path, *, lease_duration: float = _DURATION, timeout: float = 0.3, on_compromise: Callable[[LeaseCompromise], None] | None = None, ) -> SoftFileLease: return SoftFileLease( str(marker), timeout=timeout, lease_duration=lease_duration, heartbeat_interval=_HEARTBEAT, on_compromise=on_compromise, ) def test_lease_publishes_its_claim(marker: Path) -> None: lease = _lease(marker) with lease: owner = lease.owner assert owner is not None assert (owner.pid, owner.hostname, owner.mode, owner.lease_duration) == ( os.getpid(), socket.gethostname(), "lease", _DURATION, ) assert owner.token == lease.token def test_lease_token_names_the_claim_only_while_held(marker: Path) -> None: lease = _lease(marker) with lease: held = lease.token assert held is not None assert lease.token is None def test_lease_heartbeat_keeps_a_live_claim_past_its_duration(marker: Path) -> None: holder = _lease(marker) with holder: time.sleep(_DURATION * 1.5) # only a refreshing heartbeat keeps the claim past this with pytest.raises(Timeout): _lease(marker).acquire() @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_lease_peer_takes_an_expired_claim(marker: Path, mocker: MockerFixture) -> None: # A wedged holder: its marker stays on disk, but no refresh ever lands on it again, so the claim ages out. mocker.patch("filelock._lease.touch") holder = _lease(marker) holder.acquire() try: peer = _lease(marker, timeout=_DURATION * 5) with peer: assert peer.is_lock_held_by_us assert peer.token != holder.token finally: holder.release() def test_lease_self_heals_a_malformed_marker(marker: Path) -> None: # A partial write or a foreign file leaves a marker the lease parser cannot read. Rather than block every # contender until timeout, the base self-heal evicts it once it ages past the malformed grace window. marker.write_text("not a protocol 2 record\n", encoding="utf-8") os.utime(marker, (0, 0)) with _lease(marker) as lease: assert lease.is_lock_held_by_us def test_lease_reclaims_a_dead_same_host_holder(marker: Path) -> None: marker.write_text( f"filelock/2\npid=999999\nhost={socket.gethostname()}\nmode=lease\ntoken=abc\nduration={_DURATION!r}\n", encoding="utf-8", ) with _lease(marker) as lease: assert lease.is_lock_held_by_us @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_compromise_when_the_marker_vanishes(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) with lease: token = lease.token marker.unlink() time.sleep(_HEARTBEAT * 5) assert [(c.reason, c.token, c.lock_file) for c in seen] == [("marker-missing", token, str(marker))] @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_compromise_when_a_peer_takes_over(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] holder = _lease(marker, on_compromise=seen.append) peer = _lease(marker) try: with holder: marker.unlink() peer.acquire() # a peer publishes a fresh marker at the same path time.sleep(_HEARTBEAT * 5) assert [c.reason for c in seen] == ["owner-changed"] finally: peer.release() # stop the peer's heartbeat here, so its release log never lands in a later test's caplog def test_lease_reports_compromise_when_a_refresh_fails(marker: Path, mocker: MockerFixture) -> None: seen: list[LeaseCompromise] = [] failure = OSError("cannot touch the marker") mocker.patch("filelock._lease.touch", side_effect=failure) lease = _lease(marker, on_compromise=seen.append) with lease: # A starved heartbeat can miss one refresh margin; the failure is deduplicated, so it still reports once. deadline = time.monotonic() + _DURATION * 20 while not seen and time.monotonic() < deadline: time.sleep(_HEARTBEAT) assert [(c.reason, c.error) for c in seen] == [("refresh-failed", failure)] @pytest.mark.parametrize("target", [pytest.param("touch", id="touch"), pytest.param("os.lstat", id="lstat")]) def test_lease_tolerates_a_transient_refresh_error(marker: Path, mocker: MockerFixture, target: str) -> None: # A transient ESTALE/EIO on the refresh path that recovers before the lease could lapse must not raise a # compromise: the marker was ours last tick, so retry rather than tell the holder to abandon its work. import filelock._lease as lease_mod ticks = itertools.count() real = cast("Callable[..., object]", lease_mod.touch if target == "touch" else os.lstat) def flaky(path: str, *args: object, **kwargs: object) -> object: if path.endswith(marker.name) and next(ticks) < 2: raise OSError(EIO, "Input/output error") return real(path, *args, **kwargs) mocker.patch(f"filelock._lease.{target}", side_effect=flaky) seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) with lease: time.sleep(_HEARTBEAT * 6) # several ticks: the first two fail, the rest recover assert seen == [] assert lease.compromise is None @NEEDS_UNLINK_OPEN_FILE def test_lease_reports_one_compromise_per_claim(marker: Path) -> None: # pragma: needs unlink-open-file seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) with lease: marker.unlink() time.sleep(_HEARTBEAT * 6) # several refreshes fail, but the holder is told once assert len(seen) == 1 @NEEDS_UNLINK_OPEN_FILE def test_lease_records_the_compromise_without_a_callback(marker: Path) -> None: # pragma: needs unlink-open-file lease = _lease(marker) with lease: marker.unlink() time.sleep(_HEARTBEAT * 5) compromise = lease.compromise assert compromise is not None assert compromise.reason == "marker-missing" def test_lease_holds_an_uncompromised_claim(marker: Path) -> None: lease = _lease(marker) with lease: time.sleep(_HEARTBEAT * 3) assert lease.compromise is None @NEEDS_UNLINK_OPEN_FILE def test_lease_can_be_released_from_the_compromise_callback(marker: Path) -> None: # pragma: needs unlink-open-file # The callback runs on the heartbeat thread, so releasing from it needs a context that thread can see, and it must # not deadlock joining itself. holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=release_the_claim, ) holder.append(lease) lease.acquire() marker.unlink() time.sleep(_HEARTBEAT * 5) assert lease.compromise is not None assert not lease.is_locked @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_lease_release_from_the_callback_needs_a_shared_context(marker: Path) -> None: # With the default thread-local context the heartbeat thread sees no claim of its own, so its release() does # nothing. Pin the trap the docstring warns about. holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = _lease(marker, on_compromise=release_the_claim) holder.append(lease) lease.acquire() try: marker.unlink() time.sleep(_HEARTBEAT * 5) assert lease.is_locked, "a thread-local release() from the heartbeat thread silently did nothing" finally: lease.release() def test_lease_records_a_compromise_without_a_callback_deterministically(marker: Path, mocker: MockerFixture) -> None: # With no callback the heartbeat still records the loss on the lease so a holder can poll .compromise. A refresh # that keeps failing drives the loss on every platform without depending on a peer taking the marker. mocker.patch("filelock._lease.touch", side_effect=OSError("cannot touch the marker")) lease = _lease(marker) with lease: deadline = time.monotonic() + _DURATION * 20 while lease.compromise is None and time.monotonic() < deadline: time.sleep(_HEARTBEAT) assert lease.compromise is not None assert lease.compromise.reason == "refresh-failed" def test_lease_release_from_the_callback_does_not_join_itself(marker: Path, mocker: MockerFixture) -> None: # The callback runs on the heartbeat thread, so releasing there must skip joining that thread onto itself. A shared # context lets the thread see the claim, and a failing refresh drives the loss deterministically on every platform. mocker.patch("filelock._lease.touch", side_effect=OSError("cannot touch the marker")) holder: list[SoftFileLease] = [] def release_the_claim(_: LeaseCompromise) -> None: holder[0].release() lease = SoftFileLease( str(marker), thread_local=False, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, on_compromise=release_the_claim, ) holder.append(lease) lease.acquire() deadline = time.monotonic() + _DURATION * 20 while lease.is_locked and time.monotonic() < deadline: time.sleep(_HEARTBEAT) assert lease.compromise is not None assert not lease.is_locked def test_lease_keeps_its_claim_when_another_thread_fails_to_acquire(marker: Path) -> None: # The context is thread-local, so a second thread contending on the same lease object must leave the holder's # claim alone: a torn-down heartbeat stops refreshing the marker and a peer takes it while the holder still holds. holder = _lease(marker) def contend() -> None: with suppress(Timeout): holder.acquire() with holder: token = holder.token contender = Thread(target=contend) contender.start() contender.join() assert holder.token == token time.sleep(_DURATION * 1.5) # only a surviving heartbeat keeps the claim past this with pytest.raises(Timeout): _lease(marker).acquire() def test_lease_hands_back_the_claim_when_the_heartbeat_cannot_start(marker: Path, mocker: MockerFixture) -> None: # A heartbeat thread the OS refuses (an rlimit reached) must not leave the claim behind: a peer would evict the # unrefreshed marker and acquire while this instance still believed it held the lease, and the acquire rollback # would raise joining a thread that never started. mocker.patch.object(Thread, "start", side_effect=RuntimeError("can't start new thread")) lease = _lease(marker) with pytest.raises(RuntimeError, match="can't start new thread"): lease.acquire() assert not lease.is_locked assert not marker.exists() lease.release(force=True) # nothing was left to release, so this must not raise def test_lease_release_stops_a_heartbeat_recorded_before_its_thread_starts(marker: Path, mocker: MockerFixture) -> None: # A shared, non-thread-local claim lets a release() run while an acquire has recorded its heartbeat but not yet # started the thread. Stubbing start() as a no-op freezes that window: release() must stop the heartbeat without # raising on a join of the unstarted thread, and must still unlink the marker so a peer cannot take a claim we # let go. mocker.patch.object(Thread, "start") lease = SoftFileLease( str(marker), timeout=0.3, lease_duration=_DURATION, heartbeat_interval=_HEARTBEAT, thread_local=False ) lease.acquire() assert lease.is_locked lease.release() # must not raise joining a thread that never started assert not lease.is_locked assert not marker.exists() def test_lease_rejects_a_peer_configured_with_another_duration(marker: Path) -> None: holder = _lease(marker) with holder, pytest.raises(LeaseSettingsMismatch, match="must agree on lease_duration"): _lease(marker, lease_duration=_DURATION * 3).acquire() @pytest.mark.requires_hard_links def test_lease_does_not_expire_a_strict_holder(marker: Path) -> None: # pragma: needs hard-link # A strict holder never agreed to be superseded by age, so a lease contender waits it out instead. with StrictSoftFileLock(str(marker)): with pytest.raises(Timeout): _lease(marker, timeout=_DURATION * 2).acquire() assert marker.exists() @pytest.mark.parametrize( ("lease_duration", "heartbeat_interval", "message"), [ pytest.param(0, None, "lease_duration must be positive", id="zero-duration"), pytest.param(-1, None, "lease_duration must be positive", id="negative-duration"), pytest.param(_DURATION, 0, "heartbeat_interval must be positive", id="zero-heartbeat"), pytest.param(_DURATION, _DURATION, "below lease_duration", id="heartbeat-equals-duration"), pytest.param(_DURATION, _DURATION * 2, "below lease_duration", id="heartbeat-over-duration"), ], ) def test_lease_rejects_incoherent_settings( marker: Path, lease_duration: float, heartbeat_interval: float | None, message: str ) -> None: with pytest.raises(ValueError, match=message): SoftFileLease(str(marker), lease_duration=lease_duration, heartbeat_interval=heartbeat_interval) def test_lease_defaults_the_heartbeat_below_the_duration(marker: Path) -> None: lease = SoftFileLease(str(marker), lease_duration=30) assert lease.lease_duration == 30 def test_lease_drops_lifetime_with_a_warning(marker: Path) -> None: with pytest.warns(UserWarning, match="lease_duration sets when a lease expires"): lease = SoftFileLease(str(marker), lease_duration=_DURATION, lifetime=5) assert lease.lifetime is None def test_lease_supersedes_a_live_holder_once_its_claim_ages_out(marker: Path, mocker: MockerFixture) -> None: # A live holder whose refresh stalled keeps its marker, yet a contender takes it once the marker ages past # lease_duration. Windows cannot delete a live holder's open marker, so drive the age branch with a non-stale # owner and an aged marker rather than a real second process. mocker.patch("filelock._lease.owner_is_stale", return_value=False) marker.write_text( f"filelock/2\npid={os.getpid()}\nhost={socket.gethostname()}\nmode=lease\ntoken=stalled\nduration={_DURATION!r}\n", encoding="utf-8", ) aged = time.time() - (_DURATION + 5) os.utime(marker, (aged, aged)) with _lease(marker) as contender: assert contender.is_lock_held_by_us assert contender.token not in {None, "stalled"} def test_lease_reports_marker_missing_when_a_refresh_cannot_stat_it(marker: Path, mocker: MockerFixture) -> None: # A vanished marker surfaces as FileNotFoundError from the refresh stat. Windows keeps an open marker undeletable, # so inject the missing stat at the agnostic os.lstat call rather than unlinking a held file. seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) lease.acquire() token = lease.token real_lstat = cast("Callable[..., os.stat_result]", os.lstat) missing = False def lstat(path: object, *args: object, **kwargs: object) -> object: if missing and str(path).endswith(marker.name): raise FileNotFoundError(ENOENT, "No such file or directory", marker.name) return real_lstat(path, *args, **kwargs) mocker.patch("filelock._lease.os.lstat", side_effect=lstat) try: missing = True time.sleep(_HEARTBEAT * 4) finally: missing = False lease.release() assert [(c.reason, c.token, c.lock_file) for c in seen] == [("marker-missing", token, str(marker))] def test_lease_reports_owner_changed_when_the_marker_identity_shifts(marker: Path, mocker: MockerFixture) -> None: # A peer that took the expired claim replaces the marker, so the refresh stat names a different inode. Drive that # agnostically by returning a stat whose identity differs from the one the holder verified at acquire. seen: list[LeaseCompromise] = [] lease = _lease(marker, on_compromise=seen.append) lease.acquire() token = lease.token real_lstat = cast("Callable[..., os.stat_result]", os.lstat) shifted = False def lstat(path: object, *args: object, **kwargs: object) -> object: st = real_lstat(path, *args, **kwargs) if shifted and str(path).endswith(marker.name): return SimpleNamespace(st_dev=st.st_dev, st_ino=st.st_ino + 1) return st mocker.patch("filelock._lease.os.lstat", side_effect=lstat) try: shifted = True time.sleep(_HEARTBEAT * 4) finally: shifted = False lease.release() assert [(c.reason, c.token) for c in seen] == [("owner-changed", token)] def test_native_lock_rejects_a_lease_duration(marker: Path) -> None: # A kernel lock lives on the inode, so no pathname age can revoke it; the option must not even be accepted. The # rejection happens at runtime, so reach it the way a caller without a type checker would. construct = cast("Callable[..., FileLock]", FileLock) with pytest.raises(TypeError, match="does not support non-default lock options: lease_duration"): construct(str(marker), lease_duration=5) filelock-3.32.2/tests/test_soft_lifetime_warning.py0000644000000000000000000001745013615410400017451 0ustar00from __future__ import annotations import sys import warnings from typing import TYPE_CHECKING, Literal import pytest from filelock import ( AsyncSoftFileLock, AsyncUnixFileLock, AsyncWindowsFileLock, SoftFileLock, SoftFileLockLifetimeWarning, Timeout, UnixFileLock, WindowsFileLock, ) if TYPE_CHECKING: from pathlib import Path @pytest.mark.parametrize( ("lock_type", "strict_lock", "lease"), [ pytest.param(SoftFileLock, "StrictSoftFileLock", "SoftFileLease", id="sync"), pytest.param(AsyncSoftFileLock, "AsyncStrictSoftFileLock", "AsyncSoftFileLease", id="async"), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_soft_lifetime_warning_identifies_caller( lock_type: type[SoftFileLock | AsyncSoftFileLock], strict_lock: str, lease: str, entry_point: Literal["constructor", "setter"], tmp_path: Path, ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": warning_line = sys._getframe().f_lineno + 1 lock_type(tmp_path / "test.lock", lifetime=10) else: lock = lock_type(tmp_path / "test.lock") warning_line = sys._getframe().f_lineno + 1 lock.lifetime = 10 assert [(item.category, str(item.message), item.filename, item.lineno) for item in caught] == [ ( SoftFileLockLifetimeWarning, ( f"{lock_type.__name__}(lifetime=...) uses age-based expiry and can overlap a live holder; " f"use {lease} for expiry or {strict_lock} for fail-closed locking" ), __file__, warning_line, ) ] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_soft_lifetime_none_does_not_warn( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": lock_type(tmp_path / "test.lock", lifetime=None) else: lock = lock_type(tmp_path / "test.lock") lock.lifetime = None assert caught == [] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_invalid_soft_lifetime_does_not_warn( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") with pytest.raises(ValueError, match="finite and non-negative"): _set_invalid_lifetime(lock_type, entry_point, tmp_path / "test.lock") assert caught == [] @pytest.mark.parametrize( "lock_type", [ pytest.param(UnixFileLock, marks=pytest.mark.skipif(sys.platform == "win32", reason="Unix backend"), id="sync"), pytest.param( AsyncUnixFileLock, marks=pytest.mark.skipif(sys.platform == "win32", reason="Unix backend"), id="async", ), pytest.param( WindowsFileLock, marks=pytest.mark.skipif(sys.platform != "win32", reason="Windows backend"), id="sync" ), pytest.param( AsyncWindowsFileLock, marks=pytest.mark.skipif(sys.platform != "win32", reason="Windows backend"), id="async", ), ], ) @pytest.mark.parametrize( "entry_point", [pytest.param("constructor", id="constructor"), pytest.param("setter", id="setter")], ) def test_native_lifetime_warning_identifies_caller( lock_type: type[UnixFileLock | AsyncUnixFileLock | WindowsFileLock | AsyncWindowsFileLock], entry_point: Literal["constructor", "setter"], tmp_path: Path, ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") if entry_point == "constructor": warning_line = sys._getframe().f_lineno + 1 lock_type(tmp_path / "test.lock", lifetime=10) else: lock = lock_type(tmp_path / "test.lock") warning_line = sys._getframe().f_lineno + 1 lock.lifetime = 10 assert [(item.category, str(item.message), item.filename, item.lineno) for item in caught] == [ ( UserWarning, ( f"lifetime is ignored for {lock_type.__name__}: a native OS lock cannot be broken safely by file age; " "only SoftFileLock supports lifetime-based expiry" ), __file__, warning_line, ) ] @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) def test_soft_lifetime_singleton_match_warns_for_each_configuration( lock_type: type[SoftFileLock | AsyncSoftFileLock], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") first = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) second = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) assert (first is second, len(caught)) == (True, 2) @pytest.mark.parametrize( "lock_type", [pytest.param(SoftFileLock, id="sync"), pytest.param(AsyncSoftFileLock, id="async")], ) def test_soft_lifetime_singleton_mismatch_warns_before_rejection( lock_type: type[SoftFileLock | AsyncSoftFileLock], tmp_path: Path ) -> None: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") first = lock_type(tmp_path / "test.lock", lifetime=10, is_singleton=True) with pytest.raises(ValueError, match="lifetime"): lock_type(tmp_path / "test.lock", lifetime=20, is_singleton=True) assert (first.lifetime, len(caught)) == (10, 2) def test_soft_lifetime_polling_does_not_repeat_warning(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" holder = SoftFileLock(lock_path) with pytest.warns(SoftFileLockLifetimeWarning): contender = SoftFileLock(lock_path, lifetime=60, poll_interval=0.001, timeout=0.01) with holder, warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") with pytest.raises(Timeout): contender.acquire() assert caught == [] @pytest.mark.asyncio async def test_async_soft_lifetime_polling_does_not_repeat_warning(tmp_path: Path) -> None: lock_path = tmp_path / "test.lock" holder = AsyncSoftFileLock(lock_path) with pytest.warns(SoftFileLockLifetimeWarning): contender = AsyncSoftFileLock(lock_path, lifetime=60, poll_interval=0.001, timeout=0.01) async with holder: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") # The holder never releases here, so the acquire always times out. with pytest.raises(Timeout): # pragma: no branch await contender.acquire() assert caught == [] def _set_invalid_lifetime( lock_type: type[SoftFileLock | AsyncSoftFileLock], entry_point: Literal["constructor", "setter"], lock_path: Path, ) -> None: if entry_point == "constructor": lock_type(lock_path, lifetime=-1) else: lock = lock_type(lock_path) lock.lifetime = -1 filelock-3.32.2/tests/test_soft_stale.py0000644000000000000000000006365113615410400015242 0ustar00from __future__ import annotations import gc import os import socket import sys import threading from contextlib import contextmanager from errno import EACCES, EINTR, ENODEV, ENOSPC, EPERM from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from capabilities import CAPABILITIES from filelock import CloseErrorPolicy, SoftFileLock from filelock._identity import process_start_token from filelock._soft import _MAX_LOCK_FILE_SIZE from tests.capability_marks import NEEDS_POSIX_SIGNALS, NEEDS_SYMLINK, NEEDS_UNLINK_OPEN_FILE if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import ExceptionGroup # pragma: >=3.11 cover else: # pragma: no cover ( Path: return tmp_path / "test.lock" def _holder(pid: int, *, host: str = _HOST, start: int | None = None) -> str: return "\n".join([str(pid), host, *([] if start is None else [str(start)])]) + "\n" def test_lock_writes_pid_and_hostname(lock_path: Path) -> None: with SoftFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").strip().splitlines() assert lines[:2] == [str(os.getpid()), _HOST] # Every platform with a proven start time writes it as an integer third line; one without writes two lines. if (token := process_start_token(os.getpid())) is not None: assert lines == [str(os.getpid()), _HOST, str(token)] else: # pragma: no cover # every CI platform exposes a start time assert lines == [str(os.getpid()), _HOST] @pytest.mark.parametrize( "content", [ pytest.param(_holder(_DEAD_PID), id="two_line"), pytest.param(_holder(_DEAD_PID, start=123456789), id="three_line"), ], ) def test_stale_lock_broken_when_process_dead(lock_path: Path, mocker: MockerFixture, content: str) -> None: lock_path.write_text(content, encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=False) _assert_self_heals(lock_path) def test_stale_lock_not_broken_when_process_alive(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") mocker.patch("filelock._identity.process_alive", return_value=True) _assert_times_out(lock_path) def test_stale_lock_not_broken_different_hostname(lock_path: Path) -> None: lock_path.write_text(_holder(_DEAD_PID, host="other-host.example.com"), encoding="utf-8") _assert_times_out(lock_path) _REQUIRES_START_TOKEN: Final[pytest.MarkDecorator] = pytest.mark.skipif( process_start_token(os.getpid()) is None, reason="platform exposes no proven process start time" ) @_REQUIRES_START_TOKEN def test_recycled_pid_marker_self_heals(lock_path: Path) -> None: # A live PID (our own) whose recorded start token differs is a recycled PID: the process that wrote the marker is # gone even though the number is now in use. No mock: the real start token is read and found not to match. token = process_start_token(os.getpid()) assert token is not None lock_path.write_text(_holder(os.getpid(), start=token + 1), encoding="utf-8") _assert_self_heals(lock_path) @_REQUIRES_START_TOKEN def test_live_process_with_matching_start_token_retained(lock_path: Path) -> None: # Our own PID and our own start token: the recorded owner is this live process, so the marker must stand. lock_path.write_text(_holder(os.getpid(), start=process_start_token(os.getpid())), encoding="utf-8") _assert_times_out(lock_path) def test_live_process_without_start_token_falls_back_to_pid(lock_path: Path) -> None: # A two-line marker carries no start token, so liveness rests on the PID alone; a live PID is left held. lock_path.write_text(_holder(os.getpid()), encoding="utf-8") _assert_times_out(lock_path) @NEEDS_POSIX_SIGNALS # pragma: needs posix-signals @pytest.mark.parametrize( "errno", [pytest.param(EPERM, id="eperm"), pytest.param(ENODEV, id="unexpected_device")], ) def test_stale_lock_not_broken_on_kill_error(lock_path: Path, mocker: MockerFixture, errno: int) -> None: lock_path.write_text(_holder(99999), encoding="utf-8") mocker.patch("filelock._identity.os.kill", side_effect=OSError(errno, "kill failed")) _assert_times_out(lock_path) @pytest.mark.parametrize( "content", [ pytest.param(b"not-a-pid\n", id="malformed"), pytest.param(b"", id="empty"), pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), id="oversized"), pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"), pytest.param(f"{_DEAD_PID}\nhostname\nnot-a-token\n".encode(), id="three_line_bad_start_token"), ], ) def test_unparseable_lock_evicted_when_old(lock_path: Path, content: bytes) -> None: lock_path.write_bytes(content) os.utime(lock_path, (0, 0)) # An unreadable lock (bad line count, non-integer pid/start token, empty, or oversized) must self-heal # instead of staying stuck; a matching line count alone does not make a file well-formed. _assert_self_heals(lock_path) @pytest.mark.parametrize( "content", [ pytest.param(b"not-a-pid\n", id="malformed"), pytest.param(b"", id="empty"), pytest.param(b"not-a-pid\nhostname\n", id="two_line_bad_pid"), ], ) def test_unparseable_lock_not_evicted_when_fresh(lock_path: Path, content: bytes) -> None: lock_path.write_bytes(content) _assert_times_out(lock_path) @pytest.mark.parametrize( "pid", [ pytest.param(0, id="zero"), pytest.param(-1, id="negative"), pytest.param(2**31, id="oversized"), ], ) def test_out_of_range_pid_self_heals_when_old(lock_path: Path, pid: int) -> None: lock_path.write_text(_holder(pid), encoding="utf-8") os.utime(lock_path, (0, 0)) # pid 0 or -1 makes os.kill probe the caller's own process group (reads as alive), so the lock is never # reclaimed; an oversized pid raises OverflowError out of stale detection. Both are malformed and must # self-heal, matching what _parse_marker_bytes rejects. _assert_self_heals(lock_path) def test_out_of_range_pid_not_evicted_when_fresh(lock_path: Path) -> None: lock_path.write_text(_holder(0), encoding="utf-8") # A fresh out-of-range pid is malformed too, but like any malformed lock it is left alone until it ages # past the threshold, so a peer mid-write is not mistaken for a stale lock. _assert_times_out(lock_path) 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) mocker.patch.object(Path, "rename", side_effect=FileNotFoundError("already gone")) _assert_times_out(lock_path) @NEEDS_SYMLINK def test_symlinked_lock_file_is_not_followed(tmp_path: Path, lock_path: Path) -> None: # pragma: needs symlink target = tmp_path / "target" target.write_text(_holder(99999), encoding="utf-8") lock_path.symlink_to(target) # Neither the pid read nor stale detection may follow the symlink onto the target file. assert SoftFileLock(lock_path).pid is None assert target.read_text(encoding="utf-8") == _holder(99999) def test_fifo_lock_file_does_not_block(lock_path: Path) -> None: if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # An attacker-placed FIFO must not stall the open; O_NONBLOCK makes the read bail instead of hang. os.mkfifo(lock_path) # pragma: win32 no cover assert SoftFileLock(lock_path).pid is None # pragma: win32 no cover def test_fifo_lock_file_with_attached_writer_self_heals(lock_path: Path) -> None: if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # A same-UID peer can plant a FIFO with a writer attached so a non-blocking read would raise EAGAIN. The lstat # guard classifies it as a malformed lock before any open, so an aged FIFO self-heals like any other node. os.mkfifo(lock_path) # pragma: win32 no cover reader = os.open(lock_path, os.O_RDONLY | os.O_NONBLOCK) # pragma: win32 no cover writer = os.open(lock_path, os.O_WRONLY | os.O_NONBLOCK) # pragma: win32 no cover try: # pragma: win32 no cover os.utime(lock_path, (0, 0)) _assert_self_heals(lock_path) finally: os.close(reader) # pragma: win32 no cover os.close(writer) # pragma: win32 no cover def test_socket_lock_file_self_heals(lock_path: Path) -> None: if sys.platform == "win32": # pragma: win32 cover pytest.skip("AF_UNIX sockets are unix-only") sock = socket.socket(socket.AF_UNIX) # pragma: win32 no cover try: # pragma: win32 no cover sock.bind(str(lock_path)) except OSError: # pragma: darwin cover sock.close() # pragma: darwin cover pytest.skip("AF_UNIX path too long for this temp dir") # pragma: darwin cover # A Unix-domain socket cannot be os.open()ed as a file. Before the lstat guard the failed open was swallowed by # stale detection so acquisition wedged; an aged socket now self-heals like any other non-regular node. try: # pragma: linux cover os.utime(lock_path, (0, 0)) _assert_self_heals(lock_path) finally: sock.close() # pragma: linux cover def test_stale_detection_errors_suppressed(lock_path: Path, mocker: MockerFixture) -> None: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") mock_read: MagicMock = mocker.patch("filelock._soft._read_lock_file", side_effect=OSError("read failed")) _assert_times_out(lock_path) mock_read.assert_called() @_WINDOWS_ONLY @pytest.mark.parametrize( ("captured_error", "ambient_error", "acquires"), [ pytest.param(_WIN_ERROR_INVALID_PARAMETER, _WIN_ERROR_ACCESS_DENIED, True, id="dead"), pytest.param(_WIN_ERROR_ACCESS_DENIED, _WIN_ERROR_INVALID_PARAMETER, False, id="access-denied"), ], ) def test_windows_stale_lock_uses_captured_process_error( # pragma: win32 cover lock_path: Path, mocker: MockerFixture, captured_error: int, ambient_error: int, *, acquires: bool, ) -> None: if sys.platform != "win32": # pragma: win32 cover pytest.skip("windows-only") # pragma: no cover # win32-only test; this guard never runs import ctypes def fail_open_process(_access: int, _inherit_handle: bool, _pid: int) -> None: ctypes.set_last_error(captured_error) ctypes.windll.kernel32.SetLastError(ambient_error) # ctypes function pointers do not expose a Python signature for autospec. mocker.patch("filelock._identity._KERNEL32.OpenProcess", side_effect=fail_open_process) lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") if acquires: _assert_self_heals(lock_path) else: _assert_times_out(lock_path) @_WINDOWS_ONLY def test_windows_stale_lock_broken_when_pid_recycled(lock_path: Path) -> None: # pragma: win32 cover process_marker = lock_path.with_suffix(".process") with SoftFileLock(process_marker): start = int(process_marker.read_text(encoding="utf-8").splitlines()[2]) lock_path.write_text(_holder(os.getpid(), start=start + 1), encoding="utf-8") _assert_self_heals(lock_path) @_WINDOWS_ONLY def test_windows_live_process_marker_retained(lock_path: Path) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() try: _assert_times_out(lock_path) finally: lock.release() @_WINDOWS_ONLY def test_windows_live_process_marker_without_start_token_retained(lock_path: Path) -> None: # pragma: win32 cover lock_path.write_text(_holder(os.getpid()), encoding="utf-8") _assert_times_out(lock_path) @_WINDOWS_ONLY def test_windows_process_probe_closes_handles(lock_path: Path) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() try: handle_count = _current_process_handle_count() for _attempt in range(50): _assert_times_out(lock_path, timeout=0) # A per-probe handle leak would add one handle per iteration, so ~50 over the loop. Assert no growth beyond a # small margin rather than exact equality, which another thread opening or closing a handle would break. assert _current_process_handle_count() <= handle_count + 5 finally: lock.release() @pytest.mark.parametrize( ("content", "expected"), [ pytest.param(None, None, id="no_file"), pytest.param(b"not-a-number\n", None, id="malformed"), pytest.param(b"\xff\xfe\n", None, id="non_utf8"), pytest.param(b"x" * (_MAX_LOCK_FILE_SIZE + 1), None, id="oversized"), pytest.param(b"42\n", None, id="single_line"), pytest.param(_holder(0).encode(), None, id="out_of_range_pid"), pytest.param(_holder(os.getpid()).encode(), os.getpid(), id="valid"), ], ) def test_pid(lock_path: Path, content: bytes | None, expected: int | None) -> None: if content is not None: lock_path.write_bytes(content) assert SoftFileLock(lock_path).pid == expected def test_pid_while_locked(lock_path: Path) -> None: lock = SoftFileLock(lock_path) with lock: assert lock.pid == os.getpid() @pytest.mark.parametrize( ("content", "expected"), [ pytest.param(None, False, id="no_file"), pytest.param(_holder(os.getpid() + 1), False, id="different_pid"), pytest.param(_holder(os.getpid()), True, id="same_pid"), pytest.param(_holder(os.getpid(), host="other-host"), False, id="same_pid_different_host"), ], ) def test_is_lock_held_by_us(lock_path: Path, content: str | None, expected: bool) -> None: if content is not None: lock_path.write_text(content, encoding="utf-8") assert SoftFileLock(lock_path).is_lock_held_by_us is expected @pytest.mark.parametrize( "exists", [pytest.param(True, id="exists"), pytest.param(False, id="missing")], ) def test_break_lock(lock_path: Path, *, exists: bool) -> None: if exists: lock_path.write_text(_holder(os.getpid()), encoding="utf-8") SoftFileLock(lock_path).break_lock() assert not lock_path.exists() def test_write_failure_rolls_back_acquire(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", side_effect=OSError(ENOSPC, "No space left on device")) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="No space left on device"): lock.acquire() assert not lock.is_locked assert not lock_path.exists() def _assert_self_heals(lock_path: Path) -> None: lock = SoftFileLock(lock_path, timeout=1) with lock: assert lock.is_locked def _assert_times_out(lock_path: Path, *, timeout: float = 0.1) -> None: with pytest.raises(TimeoutError): SoftFileLock(lock_path, timeout=timeout).acquire() def _current_process_handle_count() -> int: # pragma: win32 cover if sys.platform != "win32": # pragma: win32 cover pytest.skip("windows-only") # pragma: no cover # only ever called on win32, so this guard never runs import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.GetCurrentProcess.argtypes = [] kernel32.GetCurrentProcess.restype = wintypes.HANDLE kernel32.GetProcessHandleCount.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] kernel32.GetProcessHandleCount.restype = wintypes.BOOL count = wintypes.DWORD() assert kernel32.GetProcessHandleCount(kernel32.GetCurrentProcess(), ctypes.byref(count)), ctypes.WinError( ctypes.get_last_error() ) return count.value def _hold_reports_foreign_identity(mocker: MockerFixture) -> None: # Make the held descriptor report a different inode than the file on disk, as if a peer replaced the marker at the # path. fstat runs only on our own lock fd, so nothing else in acquire or release is disturbed. mocker.patch("filelock._soft.os.fstat", return_value=mocker.Mock(st_dev=1, st_ino=999)) def test_short_writes_still_write_the_whole_record(lock_path: Path, mocker: MockerFixture) -> None: real_write = os.write mocker.patch("filelock._util.os.write", side_effect=lambda fd, data: real_write(fd, bytes(data[:1]))) with SoftFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").splitlines() assert lines[0] == str(os.getpid()) assert lines[1] == _HOST def test_zero_write_rolls_back_acquire(lock_path: Path, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="0 bytes"): lock.acquire() assert not lock.is_locked assert not lock_path.exists() 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) lock = SoftFileLock(lock_path) with pytest.raises(OSError, match="No space left on device"): lock.acquire() assert lock_path.exists() def test_release_without_identity_skips_unlink(lock_path: Path, mocker: MockerFixture) -> None: lock = SoftFileLock(lock_path) lock.acquire() # The held descriptor can no longer be identified, so release cannot prove the path is still ours: it closes and # clears held state without unlinking rather than risk deleting a successor's marker. mocker.patch("filelock._soft.os.fstat", side_effect=OSError) lock.release() assert not lock.is_locked assert lock_path.exists() def test_normal_release_removes_own_marker(lock_path: Path) -> None: with SoftFileLock(lock_path): assert lock_path.exists() assert not lock_path.exists() def test_del_suppresses_a_release_error(lock_path: Path, mocker: MockerFixture) -> None: # A dropped, still-held lock is finalized by __del__. A release error there must not escape as an unraisable # exception during garbage collection, which pytest would fail on and attribute to an unrelated test. Finalizing # under a raising release and forcing collection here leaves the run clean only if __del__ swallows the error. lock = SoftFileLock(lock_path) lock.acquire() mocker.patch.object(SoftFileLock, "release", side_effect=OSError("release failed")) del lock gc.collect() @NEEDS_UNLINK_OPEN_FILE def test_stale_release_spares_a_successor(lock_path: Path) -> None: # pragma: needs unlink-open-file holder = SoftFileLock(lock_path) holder.acquire() # A peer breaks the stale marker and installs its own at the same path; unlink first so it gets a fresh inode. lock_path.unlink() lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") holder.release() assert lock_path.exists() assert str(_DEAD_PID) in lock_path.read_text(encoding="utf-8") @_WINDOWS_ONLY def test_windows_release_spares_a_replacement(lock_path: Path, mocker: MockerFixture) -> None: # pragma: win32 cover lock = SoftFileLock(lock_path) lock.acquire() _hold_reports_foreign_identity(mocker) lock.release() assert lock_path.exists() lock_path.unlink() @pytest.mark.parametrize( ("policy", "surfaces"), [ pytest.param("default", True, id="default"), pytest.param("raise", True, id="raise"), pytest.param("suppress", False, id="suppress"), ], ) def test_close_error_policy_cleans_marker( lock_path: Path, mocker: MockerFixture, policy: CloseErrorPolicy, *, surfaces: bool, ) -> None: lock = SoftFileLock(lock_path, close_error_policy=policy) lock.acquire() with _close_after_commit(mocker) as (close_error, attempts): if surfaces: with pytest.raises(OSError, match="close failed") as info: lock.release() assert info.value is close_error else: lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( ("depth", "force"), [pytest.param(2, False, id="nested"), pytest.param(2, True, id="forced")], ) def test_close_error_suppression_releases_once( lock_path: Path, mocker: MockerFixture, depth: int, *, force: bool, ) -> None: lock = SoftFileLock(lock_path, close_error_policy="suppress") for _acquisition in range(depth): lock.acquire() with _close_after_commit(mocker) as (_, attempts): if force: lock.release(force=True) else: for _release in range(depth): lock.release() assert (len(attempts), lock.is_locked, lock.lock_counter, lock_path.exists()) == (1, False, 0, False) @pytest.mark.parametrize( "use_proxy", [pytest.param(False, id="direct"), pytest.param(True, id="proxy")], ) def test_context_surfaces_close_error_after_cleanup( lock_path: Path, mocker: MockerFixture, *, use_proxy: bool, ) -> None: lock = SoftFileLock(lock_path) with ( _close_after_commit(mocker) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info, lock.acquire() if use_proxy else lock, ): pass assert (info.value, len(attempts), lock.is_locked, lock_path.exists()) == (close_error, 1, False, False) def test_second_release_does_not_close_reused_descriptor( lock_path: Path, tmp_path: Path, mocker: MockerFixture, ) -> None: lock = SoftFileLock(lock_path) lock.acquire() with _close_after_commit(mocker) as (close_error, attempts), pytest.raises(OSError, match="close failed") as info: lock.release() assert info.value is close_error reused_fd = os.open(tmp_path / "reused", os.O_CREAT | os.O_WRONLY) assert reused_fd == attempts[0] try: lock.release() os.fstat(reused_fd) finally: os.close(reused_fd) @NEEDS_UNLINK_OPEN_FILE # pragma: needs unlink-open-file def test_close_error_spares_successor_marker(lock_path: Path, mocker: MockerFixture) -> None: holder = SoftFileLock(lock_path) holder.acquire() lock_path.unlink() lock_path.write_text(_holder(_DEAD_PID), encoding="utf-8") with _close_after_commit(mocker) as (_, _attempts), pytest.raises(OSError, match="close failed"): holder.release() assert lock_path.read_text(encoding="utf-8") == _holder(_DEAD_PID) def test_close_and_marker_cleanup_failures_are_grouped(lock_path: Path, mocker: MockerFixture) -> None: lock = SoftFileLock(lock_path) lock.acquire() cleanup_error = RuntimeError("cleanup failed") unlink_mock = mocker.patch("filelock._soft.Path.unlink", side_effect=cleanup_error) with _close_after_commit(mocker) as (close_error, _attempts), pytest.raises(ExceptionGroup) as info: lock.release() mocker.stop(unlink_mock) assert ( info.value.message, info.value.exceptions, close_error.__context__, cleanup_error.__context__, lock.is_locked, ) == ( "lock descriptor close and marker cleanup both failed", (close_error, cleanup_error), None, None, False, ) def test_close_after_commit_ignores_closes_from_other_threads(mocker: MockerFixture) -> None: # The patch binds os.close process-wide, so a descriptor closed on another thread must pass through cleanly and # stay out of the caller's recorded attempts rather than absorb the injected failure. closed: list[int] = [] def close_a_pipe() -> None: read_fd, write_fd = os.pipe() os.close(read_fd) os.close(write_fd) # a non-caller thread: passes through untouched rather than raising the injected error closed.extend((read_fd, write_fd)) with _close_after_commit(mocker) as (_close_error, attempts): worker = threading.Thread(target=close_a_pipe) worker.start() worker.join() assert len(closed) == 2 assert attempts == [] @contextmanager def _close_after_commit(mocker: MockerFixture) -> Iterator[tuple[OSError, list[int]]]: real_close = os.close close_error = OSError(EINTR, "close failed") attempts: list[int] = [] # close is an attribute of the shared os module, so this patch binds for every thread rather than just this one. # Leave another thread's close alone: counting it would inflate attempts, and failing it would break whatever # unrelated work happens to be closing a descriptor while this window is open. caller = threading.get_ident() def close(fd: int) -> None: real_close(fd) if threading.get_ident() != caller: return attempts.append(fd) raise close_error close_mock = mocker.patch("filelock._soft.os.close", side_effect=close) try: yield close_error, attempts finally: mocker.stop(close_mock) def test_soft_windows_unlink_gives_up_after_every_attempt_is_denied(tmp_path: Path, mocker: MockerFixture) -> None: # Windows can still hold a handle just after close, so the marker unlink retries on EACCES. Deny every attempt to # prove the retry runs and then stops, and that the denial never escapes to the caller. from filelock._soft import _file_identity marker = tmp_path / "a" marker.write_text("x", encoding="utf-8") lock = SoftFileLock(marker) mocker.patch("filelock._soft.time.sleep") mocker.patch("filelock._soft.Path.unlink", side_effect=PermissionError(EACCES, "handle still open")) lock._windows_unlink_if_ours(_file_identity(os.lstat(marker))) assert marker.exists() filelock-3.32.2/tests/test_strict_soft.py0000644000000000000000000004113713615410400015435 0ustar00from __future__ import annotations import errno import os import socket import sys import time from errno import EBADF, ENOSYS, EXDEV from pathlib import Path from typing import TYPE_CHECKING, Final import pytest from tests.capability_marks import NEEDS_FILE_MODE if sys.version_info >= (3, 11): from builtins import ExceptionGroup # pragma: >=3.11 cover else: # pragma: <3.11 cover from exceptiongroup import ExceptionGroup from capabilities import CAPABILITIES from filelock import ( AsyncStrictSoftFileLock, SoftFileLock, SoftFileLockProtocolError, StrictSoftFileClaim, StrictSoftFileClaimState, StrictSoftFileLock, Timeout, ) from filelock._identity import process_start_token from filelock._strict import _PRIVATE_RECORD_MARKER, _probe_hard_link_unsupported_errnos if TYPE_CHECKING: from pytest_mock import MockerFixture _SENTINEL: Final[bytes] = b"1\nfilelock-strict-v1\x00\n0\n" pytestmark = pytest.mark.requires_hard_links def test_strict_soft_acquire_publishes_owner_claim(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) with lock: held, intent = lock.claims owner = { "token": held.token, "pid": os.getpid(), "hostname": socket.gethostname(), "start": process_start_token(os.getpid()), } assert (held, intent) == ( StrictSoftFileClaim(name=held.name, state="held", **owner), StrictSoftFileClaim(name=intent.name, state="intent", **owner), ) assert (lock_path.read_bytes(), lock.claims, lock.is_locked) == (_SENTINEL, (), False) def test_strict_soft_is_reentrant(tmp_path: Path) -> None: lock = StrictSoftFileLock(tmp_path / "resource.lock") with lock: with lock: assert (lock.is_locked, lock.lock_counter, len(lock.claims)) == (True, 2, 2) assert (lock.is_locked, lock.lock_counter, len(lock.claims)) == (True, 1, 2) assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) def test_strict_soft_contender_times_out(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = StrictSoftFileLock(lock_path) with holder, pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() with StrictSoftFileLock(lock_path, timeout=0) as acquired: assert acquired.is_locked def test_strict_soft_waits_for_legacy_holder(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" legacy = SoftFileLock(lock_path) strict = StrictSoftFileLock(lock_path, timeout=0) with legacy, pytest.raises(Timeout): strict.acquire() with strict: assert strict.is_locked def test_legacy_client_cannot_cross_permanent_sentinel(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass os.utime(lock_path, (0, 0)) with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() assert lock_path.read_bytes() == _SENTINEL def test_legacy_client_never_reclaims_permanent_sentinel(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock_path.write_bytes(_SENTINEL) os.utime(lock_path, (0, 0)) mocker.patch("filelock._identity.socket.gethostname", return_value="filelock-strict-v1\x00") mocker.patch("filelock._identity.os.kill", side_effect=ProcessLookupError) with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() assert lock_path.read_bytes() == _SENTINEL def test_strict_soft_rejects_mutable_descriptor_hook(tmp_path: Path) -> None: with pytest.raises(ValueError, match="on_acquired is not supported"): StrictSoftFileLock(tmp_path / "resource.lock", on_acquired=lambda _fd: None) def test_strict_soft_preserves_sentinel_when_requested(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path, preserve_lock_file=True): pass assert lock_path.read_bytes() == _SENTINEL @pytest.mark.parametrize( "lock_type", [ pytest.param(StrictSoftFileLock, id="sync"), pytest.param(AsyncStrictSoftFileLock, id="async"), ], ) def test_strict_soft_constructor_rejects_lifetime( tmp_path: Path, lock_type: type[StrictSoftFileLock | AsyncStrictSoftFileLock], ) -> None: with pytest.warns(UserWarning, match=rf"lifetime is ignored for {lock_type.__name__}") as captured: lock = lock_type(tmp_path / "resource.lock", lifetime=1) assert (lock.lifetime, captured[0].filename) == (None, __file__) @pytest.mark.parametrize( "lock_type", [ pytest.param(StrictSoftFileLock, id="sync"), pytest.param(AsyncStrictSoftFileLock, id="async"), ], ) def test_strict_soft_setter_rejects_lifetime( tmp_path: Path, lock_type: type[StrictSoftFileLock | AsyncStrictSoftFileLock], ) -> None: lock = lock_type(tmp_path / "resource.lock") with pytest.warns(UserWarning, match=rf"lifetime is ignored for {lock_type.__name__}") as captured: lock.lifetime = 1 assert (lock.lifetime, captured[0].filename) == (None, __file__) @pytest.mark.parametrize( "claim_name", [ pytest.param("", id="empty"), pytest.param(".private.tmp", id="private"), pytest.param("../claim", id="parent"), pytest.param("directory/claim", id="slash"), pytest.param(r"directory\claim", id="backslash"), pytest.param("claim\0name", id="null"), ], ) def test_strict_soft_force_break_rejects_non_basename(tmp_path: Path, claim_name: str) -> None: with pytest.raises(ValueError, match="one public claim basename"): StrictSoftFileLock(tmp_path / "resource.lock").force_break(claim_name) def test_strict_soft_force_break_names_guarantee_loss(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = StrictSoftFileLock(lock_path) holder.acquire() breaker = StrictSoftFileLock(lock_path) for claim in holder.claims: breaker.force_break(claim.name) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert (holder.is_locked, contender.is_locked) == (True, True) holder.release() def test_strict_soft_force_break_requires_exact_case(tmp_path: Path) -> None: holder = StrictSoftFileLock(tmp_path / "resource.lock") holder.acquire() names = [claim.name for claim in holder.claims] with pytest.raises(FileNotFoundError): StrictSoftFileLock(holder.lock_file).force_break(names[0].upper()) assert [claim.name for claim in holder.claims] == names holder.release() def test_strict_soft_force_break_accepts_newer_version_claim(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim_name = "held-v2-opaque.claim" (claims / claim_name).write_text("filelock-strict-v2\n", encoding="ascii", newline="") lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(SoftFileLockProtocolError, match="unknown claim name or protocol version"): lock.acquire() lock.force_break(claim_name) with lock: assert lock.is_locked @pytest.mark.parametrize( ("claim_name", "content", "reason"), [ pytest.param("held-v0-dead.claim", b"old\n", "unknown claim name or protocol version", id="old-version"), pytest.param("unrecognized", b"unknown\n", "unknown claim name or protocol version", id="unknown-name"), pytest.param( "held-v1-gggggggggggggggggggggggggggggggg.claim", b"unknown\n", "unknown claim name or protocol version", id="invalid-token", ), pytest.param( "held-v1-00000000000000000000000000000000.claim", b"truncated\n", "malformed claim record", id="malformed-record", ), ], ) def test_strict_soft_invalid_claim_fails_closed(tmp_path: Path, claim_name: str, content: bytes, reason: str) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / claim_name).write_bytes(content) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(SoftFileLockProtocolError, match=reason) as raised: lock.acquire() assert (raised.value.lock_file, raised.value.claim_name, raised.value.reason, lock.is_locked) == ( str(lock_path), claim_name, reason, False, ) @pytest.mark.parametrize("state", [pytest.param("intent", id="intent"), pytest.param("held", id="held")]) def test_strict_soft_orphan_claim_blocks_without_reclamation(tmp_path: Path, state: StrictSoftFileClaimState) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claim_name = f"{state}-v1-{token}.claim" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / claim_name).write_text( f"filelock-strict-v1\n{token}\n{os.getpid()}\n{socket.gethostname().encode().hex()}\n4242\n", encoding="ascii", newline="", ) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.claims == ( StrictSoftFileClaim( name=claim_name, state=state, token=token, pid=os.getpid(), hostname=socket.gethostname(), start=4242 ), ) def test_strict_soft_rejects_non_directory_coordination_path(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" Path(f"{lock_path}.filelock").write_text("not a directory", encoding="utf-8") with pytest.raises(SoftFileLockProtocolError, match="is not a real directory"): StrictSoftFileLock(lock_path).acquire() @NEEDS_FILE_MODE # pragma: needs file-mode def test_strict_soft_unreadable_claim_fails_closed(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim = Path(f"{lock_path}.filelock") / "claims" / lock.claims[0].name claim.chmod(0) try: with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims finally: claim.chmod(0o600) lock.release() @pytest.mark.skipif(not CAPABILITIES["symlink"], reason="stages a claim as a symlink") def test_strict_soft_symlink_claim_fails_closed(tmp_path: Path) -> None: # pragma: needs symlink lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) target = tmp_path / "claim-target" target.write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n") claim = claims / f"held-v1-{'0' * 32}.claim" claim.symlink_to(target) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( "error", [ pytest.param(OSError(EXDEV, "cross-device link"), id="filesystem"), pytest.param(OSError(ENOSYS, "link unavailable"), id="kernel"), pytest.param(NotImplementedError("hard links unavailable"), id="runtime"), ], ) def test_strict_soft_hard_link_failure_names_filesystem_contract( tmp_path: Path, mocker: MockerFixture, error: OSError | NotImplementedError ) -> None: mocker.patch("filelock._strict.os.link", side_effect=error) with pytest.raises(SoftFileLockProtocolError, match="atomic no-replace hard-link"): StrictSoftFileLock(tmp_path / "resource.lock").acquire() @pytest.mark.parametrize( ("defined", "expected"), [ pytest.param({"ENOTSUP": 4242, "EOPNOTSUPP": 4343}, {ENOSYS, EXDEV, 4242}, id="enotsup"), pytest.param({"EOPNOTSUPP": 4343}, {ENOSYS, EXDEV, 4343}, id="eopnotsupp"), pytest.param({}, {ENOSYS, EXDEV}, id="neither"), ], ) def test_strict_soft_hard_link_unsupported_errnos( monkeypatch: pytest.MonkeyPatch, defined: dict[str, int], expected: set[int] ) -> None: for name in ("ENOTSUP", "EOPNOTSUPP"): monkeypatch.delattr(errno, name, raising=False) for name, code in defined.items(): monkeypatch.setattr(errno, name, code, raising=False) assert _probe_hard_link_unsupported_errnos() == expected def test_strict_soft_write_failure_leaves_no_public_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass mocker.patch("filelock._util.os.write", side_effect=OSError("write failed")) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="write failed"): lock.acquire() assert (lock.claims, tuple((Path(f"{lock_path}.filelock") / "claims").iterdir()), lock.is_locked) == ( (), (), False, ) def test_strict_soft_sentinel_inspection_failure_closes_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): pass real_fstat = os.fstat inspected_fd: int | None = None def fail_sentinel_inspection(fd: int) -> os.stat_result: nonlocal inspected_fd if os.lseek(fd, 0, os.SEEK_CUR) == len(_SENTINEL): inspected_fd = fd msg = "sentinel inspection failed" raise OSError(msg) return real_fstat(fd) mocker.patch("filelock._strict.os.fstat", side_effect=fail_sentinel_inspection) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="sentinel inspection failed"): lock.acquire() assert inspected_fd is not None # A closed descriptor reports EBADF on every platform, but the message differs (Windows says "handle is invalid"). with pytest.raises(OSError, match=r"Bad file descriptor|handle is invalid") as closed: real_fstat(inspected_fd) assert closed.value.errno == EBADF assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) def test_strict_soft_publication_and_close_failures_preserve_both_errors(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" real_close = os.close close_failed = False def fail_first_close(fd: int) -> None: nonlocal close_failed real_close(fd) # Only a dir_fd release closes a second descriptor, so elsewhere the first close is the only one. if not close_failed: # pragma: no branch close_failed = True msg = "private close failed" raise OSError(msg) mocker.patch("filelock._util.os.write", side_effect=OSError("record write failed")) mocker.patch("filelock._strict.os.close", side_effect=fail_first_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(ExceptionGroup) as raised: lock.acquire() assert [str(error) for error in raised.value.exceptions] == ["record write failed", "private close failed"] assert (lock.is_locked, lock.lock_counter, tuple(tmp_path.glob("**/*.tmp"))) == (False, 0, ()) @pytest.mark.asyncio async def test_async_strict_soft_matches_claim_protocol(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" holder = AsyncStrictSoftFileLock(lock_path) async with holder: assert holder.claims[0].state == "held" with pytest.raises(Timeout): await AsyncStrictSoftFileLock(lock_path, timeout=0).acquire() async with AsyncStrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked # The reacquire only needs proving where the holder's own open handle blocks the unlink; elsewhere release # removes the name outright and there is no sharing rule to exercise. @pytest.mark.skipif( CAPABILITIES["unlink-open-file"], reason="exercises pathname sharing where an open file resists unlink" ) def test_strict_soft_release_allows_reacquire(tmp_path: Path) -> None: # pragma: lacks unlink-open-file lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path) as first: assert first.claims[0].state == "held" with StrictSoftFileLock(lock_path, timeout=0) as second: assert second.claims[0].state == "held" def test_strict_soft_reclaims_an_aged_sentinel_private_record(tmp_path: Path) -> None: # A crash between creating a private record and linking it leaves it behind; the next acquire reclaims it once # it ages out. The dir_fd reaper cases only proved this where dir_fd exists. lock_path = tmp_path / "resource.lock" stale = tmp_path / f".{lock_path.name}{_PRIVATE_RECORD_MARKER}{'0' * 32}.tmp" stale.write_bytes(b"") aged = time.time() - 3600 os.utime(stale, (aged, aged)) with StrictSoftFileLock(lock_path): assert not stale.exists() filelock-3.32.2/tests/test_strict_soft_compat.py0000644000000000000000000000705613615410400017002 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # each client needs an isolated filelock installation import sys from typing import TYPE_CHECKING, Final, TextIO, cast import pytest from capabilities import CAPABILITIES from filelock import StrictSoftFileLock, Timeout if TYPE_CHECKING: from pathlib import Path _OLDEST_LEGACY_VERSION: Final[str] = "3.20.0" _OLD_CLIENT_PATH_VARIABLE: Final[str] = "FILELOCK_OLD_CLIENT_PATH" _OLD_HOLDER: Final[str] = """ import sys from filelock import SoftFileLock lock = SoftFileLock(sys.argv[1]) lock.acquire(timeout=5) print("acquired", flush=True) sys.stdin.readline() lock.release() print("released", flush=True) """ _OLD_PROBE: Final[str] = """ import sys from filelock import SoftFileLock, Timeout lock = SoftFileLock(sys.argv[1]) try: lock.acquire(timeout=0) except Timeout: print("blocked") else: lock.release() print("acquired") """ pytestmark = [ pytest.mark.requires_hard_links, pytest.mark.skipif( not CAPABILITIES["old-client"], reason=f"{_OLD_CLIENT_PATH_VARIABLE} must name an installed filelock {_OLDEST_LEGACY_VERSION}", ), ] @pytest.fixture(scope="module") def old_client_env() -> dict[str, str]: # pragma: needs old-client old_client_path = os.environ[_OLD_CLIENT_PATH_VARIABLE] env = os.environ.copy() # Prepend: the inherited PYTHONPATH carries the coverage plugin this child restarts under. env["PYTHONPATH"] = os.pathsep.join(part for part in (old_client_path, env.get("PYTHONPATH")) if part) installed = subprocess.run( [sys.executable, "-c", "import filelock; print(filelock.__version__)"], check=True, capture_output=True, cwd=old_client_path, env=env, text=True, ) assert installed.stdout.strip() == _OLDEST_LEGACY_VERSION return env def test_old_soft_holder_blocks_strict_client( tmp_path: Path, old_client_env: dict[str, str] ) -> None: # pragma: needs old-client lock_path = tmp_path / "resource.lock" with subprocess.Popen( [sys.executable, "-c", _OLD_HOLDER, os.fspath(lock_path)], cwd=tmp_path, env=old_client_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) as holder: assert cast("TextIO", holder.stdout).readline() == "acquired\n" with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() stdout, stderr = holder.communicate("\n", timeout=5) assert (holder.returncode, stdout, stderr) == (0, "released\n", "") with StrictSoftFileLock(lock_path, timeout=0) as strict: assert strict.is_locked def test_strict_activation_rejects_old_soft_client( tmp_path: Path, old_client_env: dict[str, str] ) -> None: # pragma: needs old-client lock_path = tmp_path / "resource.lock" with StrictSoftFileLock(lock_path): blocked = subprocess.run( [sys.executable, "-c", _OLD_PROBE, os.fspath(lock_path)], check=True, capture_output=True, cwd=tmp_path, env=old_client_env, text=True, ) after_release = subprocess.run( [sys.executable, "-c", _OLD_PROBE, os.fspath(lock_path)], check=True, capture_output=True, cwd=tmp_path, env=old_client_env, text=True, ) assert (blocked.stdout, blocked.stderr, after_release.stdout, after_release.stderr) == ( "blocked\n", "", "blocked\n", "", ) filelock-3.32.2/tests/test_strict_soft_failures.py0000644000000000000000000015126713615410400017335 0ustar00from __future__ import annotations import os import pickle # ruff:ignore[suspicious-pickle-import] # round-trip uses bytes produced in this test import socket import stat import sys import time from errno import EACCES, EEXIST, EIO, ENOENT, ESTALE, EXDEV from pathlib import Path from typing import TYPE_CHECKING, Final import pytest if sys.version_info >= (3, 11): from builtins import BaseExceptionGroup # pragma: >=3.11 cover else: # pragma: <3.11 cover from exceptiongroup import BaseExceptionGroup from capabilities import CAPABILITIES import filelock._strict from filelock import SoftFileLock, SoftFileLockProtocolError, StrictSoftFileLock, Timeout from filelock._strict import _probe_link_follow_symlinks, _relative_identity # These cases inject a failure into a directory-fd cleanup step or the reaper race, both of which only run where the # protocol uses dir_fd descriptors. Without dir_fd those branches never execute and the injected fault never fires; the # cleanup path is covered there instead by the passing stress, contention and release tests. _NEEDS_DIR_FD: Final[pytest.MarkDecorator] = pytest.mark.skipif( os.open not in os.supports_dir_fd, reason="injects a fault into the dir_fd cleanup path, which a runtime without dir_fd never executes", ) # Narrower still: GraalPy takes os.open relative to a directory descriptor but not os.link, so the backend never opens # the directory these two faults are counted against. _NEEDS_LINK_DIR_FD: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["link-dir-fd"], reason="counts the directory the dir_fd link path opens, which a runtime without link dir_fd never opens", ) if TYPE_CHECKING: from collections.abc import Iterator from pytest_mock import MockerFixture _PathValue = str | bytes | os.PathLike[str] | os.PathLike[bytes] pytestmark = pytest.mark.requires_hard_links def test_strict_soft_protocol_error_pickles() -> None: error = SoftFileLockProtocolError("resource.lock", "held-v2-claim", "unknown version") restored = pickle.loads(pickle.dumps(error)) # ruff:ignore[suspicious-pickle-usage] # input comes from the preceding local value assert (type(restored), restored.lock_file, restored.claim_name, restored.reason, str(restored)) == ( SoftFileLockProtocolError, "resource.lock", "held-v2-claim", "unknown version", "Invalid strict soft-lock state at resource.lock: claim 'held-v2-claim': unknown version", ) def test_strict_soft_claims_empty_before_first_acquire(tmp_path: Path) -> None: assert StrictSoftFileLock(tmp_path / "resource.lock").claims == () def test_strict_soft_intent_token_collision_does_not_claim_lock(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) def collide_intent( _source: _PathValue, _destination: _PathValue, **_options: int | bool | None, ) -> None: # The intent link is the only link this flow attempts before it fails closed, so colliding it unconditionally # is equivalent to matching on the intent- prefix. raise FileExistsError(EEXIST, "claim exists") mocker.patch("filelock._strict.os.link", side_effect=collide_intent) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_held_publication_failure_rolls_back_intent(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_link = os.link def fail_held( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if Path(os.fsdecode(destination)).name.startswith("held-"): raise OSError(EXDEV, "hard links unavailable") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=fail_held) lock = StrictSoftFileLock(lock_path) with pytest.raises(SoftFileLockProtocolError, match="hard-link publication"): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_held_collision_does_not_delete_foreign_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_link = os.link def publish_foreign_held( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) if Path(os.fsdecode(destination)).name.startswith("held-"): raise FileExistsError(EEXIST, "foreign claim won") mocker.patch("filelock._strict.os.link", side_effect=publish_foreign_held) with pytest.raises(FileExistsError, match="foreign claim won"): StrictSoftFileLock(lock_path).acquire() claims = StrictSoftFileLock(lock_path).claims assert (len(claims), claims[0].state) == (1, "held") @pytest.mark.parametrize("scan_to_empty", [pytest.param(2, id="intent"), pytest.param(3, id="held")]) def test_strict_soft_force_break_during_doorway_backs_off( tmp_path: Path, mocker: MockerFixture, scan_to_empty: int ) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_scandir = os.scandir scans = 0 def break_before_scan(path: str | os.PathLike[str]) -> Iterator[os.DirEntry[str]]: nonlocal scans scans += 1 if scans == scan_to_empty: for claim in Path(os.fsdecode(path)).glob("*.claim"): claim.unlink() return real_scandir(path) mocker.patch("filelock._strict.os.scandir", side_effect=break_before_scan) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) def test_strict_soft_force_break_held_during_doorway_backs_off(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_scandir = os.scandir scans = 0 def break_held_before_final_scan(path: str | os.PathLike[str]) -> Iterator[os.DirEntry[str]]: nonlocal scans scans += 1 if scans == 3: for claim in Path(os.fsdecode(path)).glob("held-*.claim"): claim.unlink() return real_scandir(path) mocker.patch("filelock._strict.os.scandir", side_effect=break_held_before_final_scan) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked) == ((), False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_reaper_removes_private_before_publication(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) real_link = os.link def reap_before_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: # The reaped private record aborts the acquisition at the intent link, so no held link ever reaches here. if Path(os.fsdecode(destination)).name.startswith("intent-"): # pragma: no branch os.utime(private_path, (0, 0)) assert StrictSoftFileLock(lock_path).claims == () real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.secrets.token_hex", return_value=token) mocker.patch("filelock._strict.os.link", side_effect=reap_before_link) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (lock.claims, lock.is_locked, private_path.exists()) == ((), False, False) def test_strict_soft_reaper_removes_private_after_publication(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) real_link = os.link def reap_after_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) if Path(os.fsdecode(destination)).name.startswith("intent-"): assert len(StrictSoftFileLock(lock_path).claims) == 1 mocker.patch("filelock._strict.secrets.token_hex", return_value=token) mocker.patch("filelock._strict.os.link", side_effect=reap_after_link) with StrictSoftFileLock(lock_path, timeout=0) as lock: assert (lock.claims[0].state, private_path.exists()) == ("held", False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_reaper_replacement_only_aborts_publisher(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) token = "0" * 32 private_path = _private_claim_path(lock_path, token) displaced_path = private_path.with_suffix(".displaced") real_link = os.link real_unlink = Path.unlink replaced = False def replace_before_unlink(path: Path, *, missing_ok: bool = False) -> None: nonlocal replaced if path == private_path and not replaced: replaced = True path.replace(displaced_path) path.write_bytes(b"replacement publisher") return real_unlink(path, missing_ok=missing_ok) def reap_before_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: # The reaped private record aborts the acquisition at the intent link, so no held link ever reaches here. if Path(os.fsdecode(destination)).name.startswith("intent-"): # pragma: no branch os.utime(private_path, (0, 0)) assert StrictSoftFileLock(lock_path).claims == () real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.secrets.token_hex", return_value=token) mocker.patch("filelock._strict.os.link", side_effect=reap_before_link) mocker.patch.object(Path, "unlink", autospec=True, side_effect=replace_before_unlink) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() with pytest.raises(SoftFileLockProtocolError, match="malformed claim record"): _ = lock.claims public_path = private_path.parent / f"intent-v1-{token}.claim" assert ( replaced, lock.is_locked, private_path.exists(), public_path.read_bytes(), displaced_path.exists(), ) == ( True, False, False, b"replacement publisher", True, ) def test_strict_soft_private_close_failure_leaves_public_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close close_failed = False def fail_private_close(fd: int) -> None: nonlocal close_failed real_close(fd) if not close_failed: close_failed = True raise OSError(EIO, "private close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_private_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="private close failed"): lock.acquire() assert ([claim.state for claim in lock.claims], lock.is_locked) == (["intent"], False) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_link_and_directory_close_failures_preserve_both(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat directory_close_failed = False def fail_directory_close(fd: int) -> None: nonlocal directory_close_failed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and not directory_close_failed: directory_close_failed = True raise OSError(EIO, "directory close failed") mocker.patch("filelock._strict.os.link", side_effect=OSError(EIO, "link failed")) mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.claims, lock.is_locked) == ( ["[Errno 5] link failed", "[Errno 5] directory close failed"], (), False, ) @_NEEDS_DIR_FD # pragma: needs dir-fd @_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_directory_close_failure_leaves_both_claims(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat directory_closes = 0 def fail_second_directory_close(fd: int) -> None: nonlocal directory_closes is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory: directory_closes += 1 if directory_closes == 2: raise OSError(EIO, "held directory close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_second_directory_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="held directory close failed"): lock.acquire() assert ([claim.state for claim in lock.claims], lock.is_locked) == (["held", "intent"], False) @pytest.mark.parametrize( "node", [ pytest.param("directory", id="directory"), pytest.param( "symlink", id="symlink", marks=pytest.mark.skipif(not CAPABILITIES["symlink"], reason="stages the private record as a symlink"), ), ], ) def test_strict_soft_reaper_leaves_non_regular_private_node(tmp_path: Path, node: str) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) private_path = _private_claim_path(lock_path, "0" * 32) if node == "directory": private_path.mkdir() else: private_path.symlink_to(tmp_path / "missing") # pragma: needs symlink with pytest.raises(SoftFileLockProtocolError, match="cannot list claim directory"): _ = StrictSoftFileLock(lock_path).claims assert private_path.is_dir() if node == "directory" else private_path.is_symlink() def test_strict_soft_reaper_does_not_retry_sharing_error(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) private_path = _private_claim_path(lock_path, "0" * 32) private_path.write_bytes(b"abandoned") os.utime(private_path, (0, 0)) unlink = mocker.patch.object( Path, "unlink", autospec=True, side_effect=PermissionError(EACCES, "sharing violation"), ) started = time.perf_counter() assert StrictSoftFileLock(lock_path).claims == () elapsed = time.perf_counter() - started assert (unlink.call_count, elapsed < 0.1, private_path.exists()) == (1, True, True) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_error_keeps_remaining_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim_name = lock.claims[0].name real_unlink = Path.unlink failed = False def fail_once(path: Path, *, missing_ok: bool = False) -> None: nonlocal failed if not failed: failed = True assert path.name == claim_name raise PermissionError(EACCES, "claim is not writable") real_unlink(path, missing_ok=missing_ok) mocker.patch("filelock._strict._UNLINK_SUPPORTS_DIR_FD", new=False) mocker.patch.object(Path, "unlink", autospec=True, side_effect=fail_once) with pytest.raises(PermissionError, match="claim is not writable"): lock.release() assert (lock.is_locked, [claim.name for claim in lock.claims]) == (True, [claim_name]) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_commits_before_directory_close_error(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() real_close = os.close real_fstat = os.fstat directory_close_failed = False def fail_directory_close(fd: int) -> None: nonlocal directory_close_failed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and not directory_close_failed: directory_close_failed = True raise OSError(EIO, "directory close failed") close_mock = mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) with pytest.raises(OSError, match="directory close failed"): lock.release() mocker.stop(close_mock) assert (lock.is_locked, lock.lock_counter, lock.claims) == (False, 0, ()) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_release_preserves_unlink_and_directory_close_errors(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() real_close = os.close real_fstat = os.fstat real_unlink = os.unlink directory_close_failed = False def fail_claim_unlink(path: _PathValue, *, dir_fd: int | None = None) -> None: if Path(os.fsdecode(path)).name.startswith("held-"): raise PermissionError(EACCES, "claim unlink failed") real_unlink(path, dir_fd=dir_fd) def fail_directory_close(fd: int) -> None: nonlocal directory_close_failed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and not directory_close_failed: directory_close_failed = True raise OSError(EIO, "directory close failed") unlink_mock = mocker.patch("filelock._strict.os.unlink", side_effect=fail_claim_unlink) supports_dir_fd_mock = mocker.patch.object(os, "supports_dir_fd", os.supports_dir_fd | {unlink_mock}) close_mock = mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) with pytest.raises(BaseExceptionGroup) as raised: lock.release() mocker.stop(unlink_mock) mocker.stop(supports_dir_fd_mock) mocker.stop(close_mock) assert ( [str(error) for error in raised.value.exceptions], lock.is_locked, lock.lock_counter, [claim.state for claim in lock.claims], ) == ( ["[Errno 13] claim unlink failed", "[Errno 5] directory close failed"], True, 1, ["held"], ) lock.release() @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_doorway_preserves_every_claim_cleanup_error(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) owner_token = "f" * 32 competitor_token = "0" * 32 competitor_name = f"held-v1-{competitor_token}.claim" real_close = os.close real_fstat = os.fstat real_scandir = os.scandir real_unlink = os.unlink scans = 0 cleanup_name: str | None = None def add_competitor_before_final_scan(path: str | os.PathLike[str]) -> Iterator[os.DirEntry[str]]: nonlocal scans scans += 1 if scans == 3: Path(path, competitor_name).write_text( f"filelock-strict-v1\n{competitor_token}\n{os.getpid()}\n{socket.gethostname().encode().hex()}\n4242\n", encoding="ascii", newline="", ) return real_scandir(path) def fail_held_unlink(path: _PathValue, *, dir_fd: int | None = None) -> None: nonlocal cleanup_name cleanup_name = Path(os.fsdecode(path)).name if cleanup_name == f"held-v1-{owner_token}.claim": raise PermissionError(EACCES, "held unlink failed") real_unlink(path, dir_fd=dir_fd) def fail_cleanup_directory_close(fd: int) -> None: is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and cleanup_name == f"intent-v1-{owner_token}.claim": raise OSError(EIO, "intent directory close failed") if is_directory and cleanup_name == f"held-v1-{owner_token}.claim": raise OSError(EIO, "held directory close failed") mocker.patch("filelock._strict.secrets.token_hex", return_value=owner_token) mocker.patch("filelock._strict.os.scandir", side_effect=add_competitor_before_final_scan) unlink_mock = mocker.patch("filelock._strict.os.unlink", side_effect=fail_held_unlink) supports_dir_fd_mock = mocker.patch.object(os, "supports_dir_fd", os.supports_dir_fd | {unlink_mock}) close_mock = mocker.patch("filelock._strict.os.close", side_effect=fail_cleanup_directory_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() mocker.stop(unlink_mock) mocker.stop(supports_dir_fd_mock) mocker.stop(close_mock) assert ( _leaf_error_messages(raised.value), [claim.name for claim in lock.claims], lock.is_locked, ) == ( [ "[Errno 13] held unlink failed", "[Errno 5] held directory close failed", "[Errno 5] intent directory close failed", ], [competitor_name, f"held-v1-{owner_token}.claim"], True, ) lock.release(force=True) lock.force_break(competitor_name) @pytest.mark.parametrize( ("sentinel", "acquires"), [ pytest.param(b"1\nfilelock-strict-v1\x00\n0\n", True, id="strict-winner"), pytest.param(b"legacy partial", False, id="legacy-winner"), ], ) def test_strict_soft_sentinel_publication_race( tmp_path: Path, mocker: MockerFixture, sentinel: bytes, *, acquires: bool ) -> None: lock_path = tmp_path / "resource.lock" real_link = os.link raced = False def publish_competitor( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: nonlocal raced if not raced: raced = True lock_path.write_bytes(sentinel) raise FileExistsError(EEXIST, "sentinel exists") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=publish_competitor) lock = StrictSoftFileLock(lock_path, timeout=0) if acquires: with lock: assert lock.is_locked else: with pytest.raises(Timeout): lock.acquire() assert lock_path.read_bytes() == sentinel def test_strict_soft_sentinel_uses_unique_private_names(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" private_names: list[str] = [] def reject_publication(source: _PathValue, _destination: _PathValue, **_options: int | bool | None) -> None: private_names.append(Path(os.fsdecode(source)).name) raise OSError(EIO, "publication failed") mocker.patch("filelock._strict.secrets.token_hex", side_effect=["0" * 32, "1" * 32]) mocker.patch("filelock._strict.os.link", side_effect=reject_publication) for _attempt in range(2): with pytest.raises(OSError, match="publication failed"): StrictSoftFileLock(lock_path).acquire() assert private_names == [ ".resource.lock.private-v1-00000000000000000000000000000000.tmp", ".resource.lock.private-v1-11111111111111111111111111111111.tmp", ] def test_strict_soft_existing_non_regular_sentinel_blocks(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" lock_path.mkdir() with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() def test_strict_soft_sentinel_race_with_non_regular_winner_blocks(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" def publish_directory( _source: _PathValue, _destination: _PathValue, **_options: int | bool | None, ) -> None: lock_path.mkdir() raise FileExistsError(EEXIST, "sentinel exists") mocker.patch("filelock._strict.os.link", side_effect=publish_directory) with pytest.raises(Timeout): StrictSoftFileLock(lock_path, timeout=0).acquire() assert lock_path.is_dir() @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_sentinel_remains_after_private_cleanup_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" real_unlink = os.unlink failed = False def fail_first_private_cleanup(path: _PathValue, *, dir_fd: int | None = None) -> None: nonlocal failed if not failed and Path(os.fsdecode(path)).name.startswith(".resource.lock.private-"): failed = True raise OSError(EIO, "private cleanup failed") real_unlink(path, dir_fd=dir_fd) mocker.patch("filelock._strict.os.unlink", side_effect=fail_first_private_cleanup) with pytest.raises(OSError, match="private cleanup failed"): StrictSoftFileLock(lock_path).acquire() with pytest.raises(Timeout): SoftFileLock(lock_path, timeout=0).acquire() with StrictSoftFileLock(lock_path, timeout=0) as strict: assert (lock_path.read_bytes(), strict.is_locked) == (b"1\nfilelock-strict-v1\x00\n0\n", True) def test_strict_soft_claim_directory_read_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) mocker.patch("filelock._strict.os.scandir", side_effect=PermissionError(EACCES, "cannot list")) with pytest.raises(SoftFileLockProtocolError, match="cannot list claim directory"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( "content", [ pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n0\n00\n4242\n", id="zero-pid"), pytest.param(b"filelock-strict-v1\n" + b"1" * 32 + b"\n1\n00\n4242\n", id="wrong-token"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\nnot-int\n", id="non-integer-start"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n-5\n", id="negative-start"), pytest.param(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n007\n", id="noncanonical-start"), ], ) def test_strict_soft_structurally_invalid_record_fails_closed(tmp_path: Path, content: bytes) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(content) with pytest.raises(SoftFileLockProtocolError, match="malformed claim record"): StrictSoftFileLock(lock_path).acquire() def test_strict_soft_accepts_maximum_pid(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{token}.claim").write_text( f"filelock-strict-v1\n{token}\n2147483647\n686f7374\n4242\n", encoding="ascii", newline="", ) assert StrictSoftFileLock(lock_path).claims[0].pid == 2**31 - 1 @pytest.mark.parametrize( ("pid", "hostname_hex"), [ pytest.param("01", "686f7374", id="leading-zero-pid"), pytest.param("2147483648", "686f7374", id="pid-above-signed-int"), pytest.param("1", "686F7374", id="uppercase-hostname-hex"), pytest.param("1", "20686f737420", id="spaced-hostname"), pytest.param("1", "686f737400", id="null-hostname"), pytest.param("1", "1b686f7374", id="escape-hostname"), ], ) def test_strict_soft_rejects_noncanonical_owner_record(tmp_path: Path, pid: str, hostname_hex: str) -> None: lock_path = tmp_path / "resource.lock" token = "0" * 32 claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{token}.claim").write_text( f"filelock-strict-v1\n{token}\n{pid}\n{hostname_hex}\n4242\n", encoding="ascii", newline="", ) with pytest.raises(SoftFileLockProtocolError, match="malformed claim record"): _ = StrictSoftFileLock(lock_path).claims @pytest.mark.parametrize( "node", [ pytest.param("directory", id="non-regular"), pytest.param("oversized", id="oversized"), ], ) def test_strict_soft_invalid_record_node_fails_closed(tmp_path: Path, node: str) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" if node == "directory": claim.mkdir() else: claim.write_bytes(b"x" * 1025) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): StrictSoftFileLock(lock_path).acquire() @pytest.mark.parametrize( ("replacement", "message"), [ pytest.param("file", "changed while opening", id="different-file"), pytest.param("directory", "is not a regular file", id="directory"), ], ) def test_strict_soft_claim_replaced_while_opening_fails_closed( tmp_path: Path, mocker: MockerFixture, replacement: str, message: str ) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" record = b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n" claim.write_bytes(record) real_open = os.open replaced = False def replace_before_open( path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None, ) -> int: nonlocal replaced if not replaced and os.fsdecode(path) == str(claim): replaced = True claim.replace(claim.with_name(f".{claim.name}.replaced")) if replacement == "file": claim.write_bytes(record) else: claim.mkdir() return real_open(path, flags, mode, dir_fd=dir_fd) mocker.patch("filelock._strict.os.open", side_effect=replace_before_open) with pytest.raises(SoftFileLockProtocolError, match=message): StrictSoftFileLock(lock_path).acquire() def _write_held_claim(claims: Path) -> Path: claims.mkdir(parents=True) claim = claims / f"held-v1-{'0' * 32}.claim" claim.write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n\n") return claim def _open_raising_for(claim: Path, error: OSError, mocker: MockerFixture) -> None: real_open = os.open def failing_open(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: if os.fsdecode(path) == str(claim): raise error # The flows using this helper open only the single target claim, so the passthrough never runs. return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: no cover mocker.patch("filelock._strict.os.open", side_effect=failing_open) def test_strict_soft_stale_claim_read_is_skipped(tmp_path: Path, mocker: MockerFixture) -> None: # An NFS peer that unlinks its own claim leaves this client's cached filehandle stale, so the open returns ESTALE # rather than ENOENT. A stale handle that outlives revalidation is a vanished claim, not an unreadable one. lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict._CLAIM_READ_GRACE", 0.02) _open_raising_for(claim, OSError(ESTALE, "Stale file handle"), mocker) assert StrictSoftFileLock(lock_path).claims == () def test_strict_soft_stale_claim_read_revalidates_to_gone(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") real_open = os.open stale_raised = False def stale_then_vanish(path: _PathValue, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: nonlocal stale_raised if not stale_raised and os.fsdecode(path) == str(claim): stale_raised = True claim.unlink() raise OSError(ESTALE, "Stale file handle") # The retry re-lstats the vanished claim and fails there before reopening, so this passthrough never runs. return real_open(path, flags, mode, dir_fd=dir_fd) # pragma: no cover mocker.patch("filelock._strict.os.open", side_effect=stale_then_vanish) assert StrictSoftFileLock(lock_path).claims == () assert stale_raised def test_strict_soft_io_error_claim_read_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") _open_raising_for(claim, OSError(EIO, "I/O error"), mocker) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims def test_strict_soft_directory_inspection_error_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: real_mkdir = Path.mkdir def fail_protocol_mkdir(path: Path, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None: if str(path).endswith(".filelock"): raise FileExistsError real_mkdir(path, mode=mode, parents=parents, exist_ok=exist_ok) mocker.patch.object(Path, "mkdir", autospec=True, side_effect=fail_protocol_mkdir) mocker.patch.object(Path, "lstat", autospec=True, side_effect=PermissionError(EACCES, "cannot inspect")) with pytest.raises(SoftFileLockProtocolError, match="cannot inspect"): StrictSoftFileLock(tmp_path / "resource.lock").acquire() def _initialize_protocol(lock_path: Path) -> None: with StrictSoftFileLock(lock_path): pass def _private_claim_path(lock_path: Path, token: str) -> Path: return Path(f"{lock_path}.filelock") / "claims" / f".intent-v1-{token}.claim.private-v1-{token}.tmp" def _leaf_error_messages(error: BaseException) -> list[str]: # pragma: needs dir-fd if isinstance(error, BaseExceptionGroup): return [message for child in error.exceptions for message in _leaf_error_messages(child)] return [str(error)] @pytest.mark.parametrize( "error", [ pytest.param(OSError(EACCES, "denied"), id="oserror"), pytest.param(NotImplementedError("no follow_symlinks"), id="notimplemented"), pytest.param(ValueError("bad option"), id="valueerror"), ], ) def test_strict_soft_link_follow_probe_reports_unhonored(mocker: MockerFixture, error: Exception) -> None: mocker.patch("filelock._strict.os.link", side_effect=error) assert _probe_link_follow_symlinks() is False def test_strict_soft_sentinel_inspection_and_close_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_fstat = os.fstat real_close = os.close fstats = 0 close_failed = False def fail_sentinel_inspection(fd: int) -> os.stat_result: nonlocal fstats fstats += 1 if fstats == 2: raise OSError(EIO, "sentinel inspection failed") return real_fstat(fd) def fail_first_close(fd: int) -> None: nonlocal close_failed if not close_failed: close_failed = True raise OSError(EIO, "sentinel close failed") # Only the sentinel descriptor is closed in this flow, so the passthrough never runs. real_close(fd) # pragma: no cover mocker.patch("filelock._strict.os.fstat", side_effect=fail_sentinel_inspection) mocker.patch("filelock._strict.os.close", side_effect=fail_first_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.is_locked) == ( ["[Errno 5] sentinel inspection failed", "[Errno 5] sentinel close failed"], False, ) @_NEEDS_DIR_FD # pragma: needs dir-fd @pytest.mark.parametrize("initialized", [pytest.param(False, id="sentinel"), pytest.param(True, id="intent")]) def test_strict_soft_publication_directory_close_failure( tmp_path: Path, mocker: MockerFixture, initialized: bool ) -> None: lock_path = tmp_path / "resource.lock" if initialized: _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat closed = False def fail_first_directory_close(fd: int) -> None: nonlocal closed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and not closed: closed = True raise OSError(EIO, "publication directory close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_first_directory_close) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(OSError, match="publication directory close failed"): lock.acquire() assert lock.is_locked is False @_NEEDS_DIR_FD # pragma: needs dir-fd def test_strict_soft_force_break_directory_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() claim_name = lock.claims[0].name real_close = os.close real_fstat = os.fstat closed = False def fail_directory_close(fd: int) -> None: nonlocal closed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and not closed: closed = True raise OSError(EIO, "force break directory close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close) with pytest.raises(OSError, match="force break directory close failed"): lock.force_break(claim_name) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_release_sentinel_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() sentinel_fd = lock._context.lock_file_fd real_close = os.close def fail_sentinel_close(fd: int) -> None: if fd == sentinel_fd: raise OSError(EIO, "sentinel close failed") # Only posix release closes a second, non-sentinel descriptor (the dir_fd); win32 closes only the sentinel. real_close(fd) # pragma: needs dir-fd mocker.patch("filelock._strict.os.close", side_effect=fail_sentinel_close) with pytest.raises(OSError, match="sentinel close failed"): lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_discard_sentinel_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) _write_foreign_claim(Path(f"{lock_path}.filelock") / "claims", "a" * 32) real_close = os.close closes = 0 def fail_second_close(fd: int) -> None: nonlocal closes closes += 1 if closes == 2: raise OSError(EIO, "doorway sentinel close failed") real_close(fd) mocker.patch("filelock._strict.os.close", side_effect=fail_second_close) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="doorway sentinel close failed"): lock.acquire() assert (lock.is_locked, [claim.state for claim in lock.claims]) == (False, ["held"]) @pytest.mark.parametrize("initialized", [pytest.param(False, id="sentinel"), pytest.param(True, id="intent")]) def test_strict_soft_publication_record_replaced_aborts( tmp_path: Path, mocker: MockerFixture, initialized: bool ) -> None: lock_path = tmp_path / "resource.lock" if initialized: _initialize_protocol(lock_path) real_identity = _relative_identity def mismatch_public(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: identity = real_identity(directory_ref, name) if identity is not None and not name.startswith("."): return identity[0], identity[1] + 1 return identity mocker.patch("filelock._strict._relative_identity", side_effect=mismatch_public) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.is_locked is False def test_strict_soft_publication_record_reclaimed_aborts(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_identity = _relative_identity def source_gone(directory_ref: tuple[str, int | None], name: str) -> tuple[int, int] | None: if name.startswith("."): return None # Only the dot-prefixed private record is inspected in this flow, so this passthrough never runs. return real_identity(directory_ref, name) # pragma: no cover mocker.patch("filelock._strict._relative_identity", side_effect=source_gone) mocker.patch("filelock._strict._link_relative", side_effect=FileNotFoundError(ENOENT, "source vanished")) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert lock.is_locked is False def test_strict_soft_private_inspection_failure_unlinks_vanished_record(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_fstat = os.fstat fstats = 0 def fail_private_inspection(fd: int) -> os.stat_result: nonlocal fstats fstats += 1 if fstats == 3: raise OSError(EIO, "private inspection failed") return real_fstat(fd) mocker.patch("filelock._strict.os.fstat", side_effect=fail_private_inspection) mocker.patch("filelock._strict._unlink_relative", side_effect=FileNotFoundError(ENOENT, "record gone")) lock = StrictSoftFileLock(lock_path) with pytest.raises(OSError, match="private inspection failed"): lock.acquire() assert lock.is_locked is False def test_strict_soft_record_finalization_close_and_unlink_failures_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close close_failed = False def fail_first_close(fd: int) -> None: nonlocal close_failed real_close(fd) if not close_failed: close_failed = True raise OSError(EIO, "private close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_first_close) mocker.patch("filelock._strict._unlink_relative", side_effect=PermissionError(EACCES, "unlink denied")) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert (sorted(str(error) for error in raised.value.exceptions), lock.is_locked) == ( ["[Errno 13] unlink denied", "[Errno 5] private close failed"], False, ) def test_strict_soft_release_claim_unlink_failures_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" lock = StrictSoftFileLock(lock_path) lock.acquire() unlink_mock = mocker.patch( "filelock._strict._unlink_in_directory", side_effect=PermissionError(EACCES, "claim unlink denied"), ) with pytest.raises(BaseExceptionGroup) as raised: lock.release() assert ([str(error) for error in raised.value.exceptions], lock.is_locked, len(lock.claims)) == ( ["[Errno 13] claim unlink denied", "[Errno 13] claim unlink denied"], True, 2, ) mocker.stop(unlink_mock) lock.release() assert (lock.is_locked, lock.claims) == (False, ()) def test_strict_soft_doorway_claim_unlink_failure_commits_owned(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) owner_token = "f" * 32 competitor_name = f"held-v1-{'0' * 32}.claim" intent_name = f"intent-v1-{owner_token}.claim" real_scandir = os.scandir injected = False def add_competitor_once_intent_is_published(path: str | os.PathLike[str]) -> Iterator[os.DirEntry[str]]: # Key on directory content, not scandir call count: the setup and publication paths scan a differing number # of times per platform, so plant the held competitor on the first rescan that already lists our own intent. nonlocal injected directory = Path(os.fsdecode(path)) if not injected: with real_scandir(directory) as entries: intent_published = any(entry.name == intent_name for entry in entries) if intent_published: _write_foreign_claim(directory, "0" * 32) injected = True return real_scandir(directory) real_unlink_in_directory = filelock._strict._unlink_in_directory def deny_only_the_owned_intent_unlink(directory: Path, name: str) -> BaseException | None: # Fail only the doorway's own intent-claim unlink. A blanket failure also breaks the Windows-only private # record cleanup inside publication (_unlink_relative unlinks a differently named private file there), firing # the fault before the doorway ever discards and masking the commit-owned path this test targets. if name == intent_name: raise PermissionError(EACCES, "intent unlink denied") return real_unlink_in_directory(directory, name) # pragma: lacks dir-fd mocker.patch("filelock._strict.secrets.token_hex", return_value=owner_token) scandir_mock = mocker.patch("filelock._strict.os.scandir", side_effect=add_competitor_once_intent_is_published) unlink_mock = mocker.patch("filelock._strict._unlink_in_directory", side_effect=deny_only_the_owned_intent_unlink) lock = StrictSoftFileLock(lock_path) with pytest.raises(PermissionError, match="intent unlink denied"): lock.acquire() assert (lock.is_locked, sorted(claim.name for claim in lock.claims)) == ( True, [competitor_name, f"intent-v1-{owner_token}.claim"], ) mocker.stop(scandir_mock) mocker.stop(unlink_mock) lock.release(force=True) lock.force_break(competitor_name) def test_strict_soft_permission_denied_claim_read_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claim = _write_held_claim(Path(f"{lock_path}.filelock") / "claims") mocker.patch("filelock._strict._CLAIM_READ_GRACE", 0.02) _open_raising_for(claim, PermissionError(EACCES, "sharing violation"), mocker) with pytest.raises(SoftFileLockProtocolError, match="cannot read claim"): _ = StrictSoftFileLock(lock_path).claims def test_strict_soft_ignores_malformed_private_record_name(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / ".decoy.tmp").write_bytes(b"junk") assert StrictSoftFileLock(lock_path).claims == () def test_strict_soft_record_read_and_close_failure_group(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(b"x" * 1025) real_close = os.close def fail_close(fd: int) -> None: # Reading the single oversized claim closes exactly one descriptor, so failing every close still fails once. real_close(fd) raise OSError(EIO, "record close failed") mocker.patch("filelock._strict.os.close", side_effect=fail_close) with pytest.raises(BaseExceptionGroup) as raised: _ = StrictSoftFileLock(lock_path).claims messages = [str(error) for error in raised.value.exceptions] assert ( any("exceeds 1024 bytes" in message for message in messages), "[Errno 5] record close failed" in messages, ) == ( True, True, ) def test_strict_soft_acquires_without_dir_fd(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" mocker.patch("filelock._strict._OPEN_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._UNLINK_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._STAT_SUPPORTS_DIR_FD", new=False) mocker.patch("filelock._strict._LINK_SUPPORTS_DIR_FD", new=False) with StrictSoftFileLock(lock_path) as lock: assert (lock.is_locked, [claim.state for claim in lock.claims]) == (True, ["held", "intent"]) assert StrictSoftFileLock(lock_path).claims == () @_NEEDS_DIR_FD # pragma: needs dir-fd @_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_link_and_directory_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) real_close = os.close real_fstat = os.fstat real_link = os.link held_failed = False directory_closed = False def fail_held_link( source: _PathValue, destination: _PathValue, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: nonlocal held_failed if Path(os.fsdecode(destination)).name.startswith("held-"): held_failed = True raise OSError(EIO, "held link failed") real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) def fail_directory_close_after_held(fd: int) -> None: nonlocal directory_closed is_directory = stat.S_ISDIR(real_fstat(fd).st_mode) real_close(fd) if is_directory and held_failed and not directory_closed: directory_closed = True raise OSError(EIO, "held link directory close failed") mocker.patch("filelock._strict.os.link", side_effect=fail_held_link) mocker.patch("filelock._strict.os.close", side_effect=fail_directory_close_after_held) lock = StrictSoftFileLock(lock_path) with pytest.raises(BaseExceptionGroup) as raised: lock.acquire() assert ([str(error) for error in raised.value.exceptions], lock.is_locked, lock.claims) == ( ["[Errno 5] held link failed", "[Errno 5] held link directory close failed"], False, (), ) def test_strict_soft_opened_record_non_regular_fails_closed(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" claims = Path(f"{lock_path}.filelock") / "claims" claims.mkdir(parents=True) (claims / f"held-v1-{'0' * 32}.claim").write_bytes(b"filelock-strict-v1\n" + b"0" * 32 + b"\n1\n686f7374\n4242\n") real_fstat = os.fstat def non_regular_fstat(fd: int) -> os.stat_result: original = real_fstat(fd) return os.stat_result((stat.S_IFIFO | 0o644, *original[1:])) mocker.patch("filelock._strict.os.fstat", side_effect=non_regular_fstat) with pytest.raises(SoftFileLockProtocolError, match="is not a regular file"): _ = StrictSoftFileLock(lock_path).claims def _write_foreign_claim(claims: Path, token: str, state: str = "held") -> Path: claim = claims / f"{state}-v1-{token}.claim" claim.write_bytes(b"filelock-strict-v1\n" + token.encode() + f"\n{os.getpid()}\n686f7374\n4242\n".encode()) return claim filelock-3.32.2/tests/test_strict_soft_paths.py0000644000000000000000000001430413615410400016630 0ustar00from __future__ import annotations import asyncio import os import threading from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from functools import partial from typing import TYPE_CHECKING, Final import pytest from filelock import AsyncStrictSoftFileLock, StrictSoftFileLock, Timeout from tests.capability_marks import NEEDS_PARENT_SYMLINK_COLLAPSE, NEEDS_SYMLINK if TYPE_CHECKING: from pathlib import Path _STRICT_SENTINEL: Final[bytes] = b"1\nfilelock-strict-v1\x00\n0\n" # Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. pytestmark = pytest.mark.requires_hard_links def test_strict_soft_relative_release_uses_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories lock = StrictSoftFileLock("resource.lock") lock.acquire() assert tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims) == ("held", "intent") os.chdir(second) lock.release() assert ( StrictSoftFileLock(first / "resource.lock").claims, StrictSoftFileLock(second / "resource.lock").claims, ) == ((), ()) @pytest.mark.asyncio async def test_async_strict_soft_relative_release_uses_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories lock = AsyncStrictSoftFileLock("resource.lock", run_in_executor=False) await lock.acquire() assert tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims) == ("held", "intent") os.chdir(second) await lock.release() assert ( StrictSoftFileLock(first / "resource.lock").claims, StrictSoftFileLock(second / "resource.lock").claims, ) == ((), ()) def test_strict_soft_waiter_keeps_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories holder = StrictSoftFileLock(first / "resource.lock") holder.acquire() polling = threading.Event() acquired = threading.Event() release = threading.Event() try: with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_hold_relative_lock, polling, acquired, release) assert polling.wait(timeout=2) os.chdir(second) holder.release() assert acquired.wait(timeout=2) assert ( tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims), StrictSoftFileLock(second / "resource.lock").claims, ) == (("held", "intent"), ()) release.set() future.result(timeout=2) finally: release.set() holder.release(force=True) @pytest.mark.asyncio async def test_async_strict_soft_waiter_keeps_acquisition_directory( working_directories: tuple[Path, Path], ) -> None: first, second = working_directories holder = AsyncStrictSoftFileLock(first / "resource.lock", run_in_executor=False) await holder.acquire() polling = asyncio.Event() contender = AsyncStrictSoftFileLock("resource.lock", timeout=2, poll_interval=0.001, run_in_executor=False) task = asyncio.create_task(contender.acquire(cancel_check=partial(_signal_async_poll, polling))) try: await asyncio.wait_for(polling.wait(), timeout=2) os.chdir(second) await holder.release() await asyncio.wait_for(task, timeout=2) assert ( tuple(claim.state for claim in StrictSoftFileLock(first / "resource.lock").claims), StrictSoftFileLock(second / "resource.lock").claims, ) == (("held", "intent"), ()) finally: if not task.done(): # pragma: no cover # the contender wins before teardown, so cancellation cleanup is rare task.cancel() with suppress(asyncio.CancelledError): await task await holder.release(force=True) await contender.release(force=True) @NEEDS_PARENT_SYMLINK_COLLAPSE # pragma: win32 no cover def test_strict_soft_release_uses_original_symlink_parent(tmp_path: Path) -> None: original = tmp_path / "original" replacement = tmp_path / "replacement" original.mkdir() replacement.mkdir() link = tmp_path / "link" link.symlink_to(original, target_is_directory=True) original_holder = StrictSoftFileLock(link / "resource.lock") replacement_holder = StrictSoftFileLock(replacement / "resource.lock") original_holder.acquire() replacement_holder.acquire() try: link.unlink() link.symlink_to(replacement, target_is_directory=True) original_holder.release() assert ( StrictSoftFileLock(original / "resource.lock").claims, tuple(claim.state for claim in replacement_holder.claims), ) == ((), ("held", "intent")) finally: original_holder.release(force=True) replacement_holder.release(force=True) @NEEDS_SYMLINK # pragma: needs symlink def test_strict_soft_final_symlink_fails_closed_without_touching_target(tmp_path: Path) -> None: target = tmp_path / "target" target.write_bytes(_STRICT_SENTINEL) lock_path = tmp_path / "resource.lock" lock_path.symlink_to(target) lock = StrictSoftFileLock(lock_path, timeout=0) with pytest.raises(Timeout): lock.acquire() assert (target.read_bytes(), lock_path.is_symlink(), lock.claims) == (_STRICT_SENTINEL, True, ()) def _hold_relative_lock(polling: threading.Event, acquired: threading.Event, release: threading.Event) -> None: with StrictSoftFileLock("resource.lock", timeout=2, poll_interval=0.001).acquire( cancel_check=partial(_signal_thread_poll, polling) ): acquired.set() assert release.wait(timeout=2) def _signal_thread_poll(polling: threading.Event) -> bool: polling.set() return False def _signal_async_poll(polling: asyncio.Event) -> bool: polling.set() return False @pytest.fixture def working_directories(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: first = tmp_path / "first" second = tmp_path / "second" first.mkdir() second.mkdir() monkeypatch.chdir(first) return first, second filelock-3.32.2/tests/test_strict_soft_races.py0000644000000000000000000002420413615410400016606 0ustar00from __future__ import annotations import os import threading import time from errno import EIO from pathlib import Path from typing import TYPE_CHECKING, Literal import pytest from filelock import StrictSoftFileLock, Timeout if TYPE_CHECKING: from pytest_mock import MockerFixture pytestmark = pytest.mark.requires_hard_links def test_strict_soft_lower_intent_delayed_until_higher_selects(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lower_intent_waiting = threading.Event() higher_held_attempted = threading.Event() lower_held_linked = threading.Event() higher_held_linked = threading.Event() real_link = os.link def ordered_link( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: name = Path(destination).name if threading.current_thread().name == "lower" and name.startswith("intent-"): lower_intent_waiting.set() assert higher_held_attempted.wait(timeout=2) if threading.current_thread().name == "higher" and name.startswith("held-"): higher_held_attempted.set() assert lower_held_linked.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) if threading.current_thread().name == "lower" and name.startswith("held-"): lower_held_linked.set() assert higher_held_linked.wait(timeout=2) elif threading.current_thread().name == "higher" and name.startswith("held-"): higher_held_linked.set() mocker.patch("filelock._strict.os.link", side_effect=ordered_link) mocker.patch( "filelock._strict.secrets.token_hex", side_effect=lambda _size: "0" * 32 if threading.current_thread().name == "lower" else "f" * 32, ) acquisition_order: list[str] = [] inside, state_lock = 0, threading.Lock() def acquire(label: str) -> None: nonlocal inside with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): with state_lock: inside += 1 assert inside == 1 acquisition_order.append(label) time.sleep(0.01) with state_lock: inside -= 1 lower = threading.Thread(target=acquire, args=("lower",), name="lower") higher = threading.Thread(target=acquire, args=("higher",), name="higher") lower.start() assert lower_intent_waiting.wait(timeout=2) higher.start() _join_threads(lower, higher) assert (acquisition_order, inside) == (["lower", "higher"], 0) def test_strict_soft_lower_intent_delayed_until_higher_enters(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lower_intent_waiting = threading.Event() higher_entered = threading.Event() release_higher = threading.Event() real_link = os.link def delayed_link( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "lower" and Path(destination).name.startswith("intent-"): lower_intent_waiting.set() assert higher_entered.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) mocker.patch("filelock._strict.os.link", side_effect=delayed_link) mocker.patch( "filelock._strict.secrets.token_hex", side_effect=lambda _size: "0" * 32 if threading.current_thread().name == "lower" else "f" * 32, ) acquisition_order: list[str] = [] def acquire(label: str) -> None: with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): acquisition_order.append(label) if label == "higher": higher_entered.set() assert release_higher.wait(timeout=2) lower = threading.Thread(target=acquire, args=("lower",), name="lower") higher = threading.Thread(target=acquire, args=("higher",), name="higher") lower.start() assert lower_intent_waiting.wait(timeout=2) higher.start() assert higher_entered.wait(timeout=2) assert acquisition_order == ["higher"] release_higher.set() _join_threads(lower, higher) assert acquisition_order == ["higher", "lower"] def test_strict_soft_private_partial_record_is_not_a_claim(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) partial_written = threading.Event() resume_write = threading.Event() real_write = os.write paused = False def paused_write(fd: int, data: bytes | bytearray | memoryview) -> int: nonlocal paused if threading.current_thread().name != "partial" or paused: return real_write(fd, data) paused = True written = real_write(fd, data[:1]) partial_written.set() assert resume_write.wait(timeout=2) return written mocker.patch("filelock._util.os.write", side_effect=paused_write) def acquire_partial() -> None: with StrictSoftFileLock(lock_path, timeout=3, poll_interval=0.001): pass partial = threading.Thread(target=acquire_partial, name="partial") partial.start() assert partial_written.wait(timeout=2) with StrictSoftFileLock(lock_path, timeout=0) as contender: assert contender.is_locked resume_write.set() _join_threads(partial) def test_strict_soft_shared_instance_waits_for_failed_doorway(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lock = StrictSoftFileLock(lock_path, thread_local=False, timeout=2, poll_interval=0.001) first_doorway = threading.Event() fail_first = threading.Event() second_entered = threading.Event() first_errors: list[OSError] = [] real_link = os.link def pause_first_intent( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "first" and Path(destination).name.startswith("intent-"): first_doorway.set() assert fail_first.wait(timeout=2) raise OSError(EIO, "doorway failed") real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) def acquire_first() -> None: try: lock.acquire() except OSError as error: first_errors.append(error) def acquire_second() -> None: with lock: second_entered.set() mocker.patch("filelock._strict.os.link", side_effect=pause_first_intent) first = threading.Thread(target=acquire_first, name="first") second = threading.Thread(target=acquire_second, name="second") first.start() assert first_doorway.wait(timeout=2) second.start() assert not second_entered.wait(timeout=0.05) fail_first.set() _join_threads(first, second) assert ([str(error) for error in first_errors], second_entered.is_set(), lock.is_locked) == ( ["[Errno 5] doorway failed"], True, False, ) @pytest.mark.parametrize( "admission", [ pytest.param("timeout", id="zero-timeout"), pytest.param("blocking", id="nonblocking"), pytest.param("cancel", id="cancel-check"), ], ) def test_strict_soft_shared_instance_transition_respects_admission( tmp_path: Path, mocker: MockerFixture, admission: Literal["timeout", "blocking", "cancel"], ) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) lock = StrictSoftFileLock(lock_path, thread_local=False, timeout=2, poll_interval=0.001) first_doorway = threading.Event() continue_first = threading.Event() release_first = threading.Event() real_link = os.link def pause_first_intent( source: str, destination: str, *, src_dir_fd: int | None = None, dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: if threading.current_thread().name == "first" and Path(destination).name.startswith("intent-"): first_doorway.set() assert continue_first.wait(timeout=2) real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks, ) def acquire_first() -> None: with lock: assert release_first.wait(timeout=2) mocker.patch("filelock._strict.os.link", side_effect=pause_first_intent) first = threading.Thread(target=acquire_first, name="first") first.start() assert first_doorway.wait(timeout=2) started = time.perf_counter() with pytest.raises(Timeout): _acquire_during_transition(lock, admission) elapsed = time.perf_counter() - started continue_first.set() release_first.set() _join_threads(first) assert (elapsed < 0.2, lock.is_locked) == (True, False) def _acquire_during_transition( lock: StrictSoftFileLock, admission: Literal["timeout", "blocking", "cancel"], ) -> None: if admission == "timeout": lock.acquire(timeout=0) elif admission == "blocking": lock.acquire(blocking=False) else: lock.acquire(cancel_check=lambda: True) def _initialize_protocol(lock_path: Path) -> None: with StrictSoftFileLock(lock_path): pass def _join_threads(*threads: threading.Thread) -> None: for thread in threads: thread.join(timeout=3) assert [thread.name for thread in threads if thread.is_alive()] == [] filelock-3.32.2/tests/test_strict_soft_recovery.py0000644000000000000000000001127513615410400017353 0ustar00from __future__ import annotations import os import subprocess # ruff:ignore[suspicious-subprocess-import] # the process must crash between two filesystem operations import sys import textwrap from typing import TYPE_CHECKING, Final import pytest from filelock import StrictSoftFileLock, Timeout if TYPE_CHECKING: from pathlib import Path # The crash points are hit through sys.addaudithook on os.link and os.remove events, which only CPython emits. PyPy # runs the audit hook but never fires those events, so the child would finish the acquisition instead of crashing. pytestmark = [ pytest.mark.skipif( sys.implementation.name != "cpython", reason="crash injection needs the os.link and os.remove audit events CPython emits", ), pytest.mark.requires_hard_links, ] _CRASH_STATUS: Final[int] = 73 def test_strict_soft_recovers_every_claim_for_crashed_token(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_while_holding(), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) lock = StrictSoftFileLock(lock_path, timeout=0) claims = lock.claims assert (result.returncode, result.stdout, result.stderr, [claim.state for claim in claims]) == ( _CRASH_STATUS, "", "", ["held", "intent"], ) assert len({claim.token for claim in claims}) == 1 with pytest.raises(Timeout): lock.acquire() for claim in lock.claims: lock.force_break(claim.name) with lock: assert lock.is_locked def test_strict_soft_reclaims_crash_before_private_publication(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_during_private_publication("before-link"), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) private_records = tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) assert (result.returncode, result.stdout, result.stderr, len(private_records)) == (_CRASH_STATUS, "", "", 1) os.utime(private_records[0], (0, 0)) lock = StrictSoftFileLock(lock_path, timeout=0) assert (lock.claims, tuple(tmp_path.glob("**/.*.private-v1-*.tmp"))) == ((), ()) with lock: assert lock.is_locked def test_strict_soft_reclaims_crash_after_private_publication(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" result = subprocess.run( [sys.executable, "-c", _crash_during_private_publication("after-link"), os.fspath(lock_path)], check=False, capture_output=True, text=True, ) private_records = tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) lock = StrictSoftFileLock(lock_path, timeout=0) claims = lock.claims assert (result.returncode, result.stdout, result.stderr, len(private_records), len(claims)) == ( _CRASH_STATUS, "", "", 1, 1, ) assert tuple(tmp_path.glob("**/.*.private-v1-*.tmp")) == () lock.force_break(claims[0].name) with lock: assert lock.is_locked def _crash_while_holding() -> str: # A strict holder keeps both its held and intent claims for the whole hold, so a hard crash mid-hold leaves the # pair behind. os._exit skips every finalizer, reproducing a killed process that never releases. return textwrap.dedent( f""" from __future__ import annotations import os import sys from filelock import StrictSoftFileLock lock = StrictSoftFileLock(sys.argv[1]) lock.acquire() os._exit({_CRASH_STATUS}) """ ) def _crash_during_private_publication(stage: str) -> str: return textwrap.dedent( f""" from __future__ import annotations import os import sys from filelock import StrictSoftFileLock def exit_at_publication_stage(event: str, args: tuple[str | int, ...]) -> None: if event not in {{"os.link", "os.remove"}}: return path = os.path.basename(os.fsdecode(args[1] if event == "os.link" else args[0])) before_link = {stage!r} == "before-link" and event == "os.link" and path.startswith("intent-") after_link = ( {stage!r} == "after-link" and event == "os.remove" and path.startswith(".intent-") and ".private-v1-" in path and path.endswith(".tmp") ) if before_link or after_link: os._exit({_CRASH_STATUS}) sys.addaudithook(exit_at_publication_stage) StrictSoftFileLock(sys.argv[1]).acquire() """ ) filelock-3.32.2/tests/test_strict_soft_stress.py0000644000000000000000000001025613615410400017036 0ustar00from __future__ import annotations import os import signal import subprocess # ruff:ignore[suspicious-subprocess-import] # isolated interpreters exercise cross-process exclusion import sys import time from typing import TYPE_CHECKING, Final import pytest if TYPE_CHECKING: from pathlib import Path _PROCESS_COUNT: Final[int] = 8 _ACQUISITIONS_PER_PROCESS: Final[int] = 500 # A guard against a deadlock, not a throughput assertion: overlap fails immediately through the markers below, so this # budget only bounds a hang. The four thousand contended acquisitions cost CPython about three seconds and GraalPy about # twenty-eight on a ten-core machine, and the CI runners have far fewer, so it is sized for the slowest interpreter on # the smallest runner rather than for the fastest, which is what a seventy-five second guard encoded. _WORKER_DEADLINE: Final[int] = 300 # Catch overlap two independent ways. An occupancy marker fails immediately the instant a second holder enters the # critical section, and a counter read-modify-write fails afterwards when two holders read the same value and one # increment vanishes. Both beat an O_EXCL create/unlink pair, which tripped over Windows' delete-pending lag where the # previous holder's unlink had not finished before the next holder's create ran. The check lives in the worker's exit # status, not in its stderr: interpreters are free to print to stderr (PyPy warns when it cannot size the CPU cache), # so demanding empty stderr flakes without proving anything about exclusion. _WORKER: Final[str] = """ import sys import time from pathlib import Path from filelock import StrictSoftFileLock lock_path, start_path, counter_path, occupied_path = map(Path, sys.argv[1:5]) while not start_path.exists(): time.sleep(0.001) for _ in range(int(sys.argv[5])): with StrictSoftFileLock(lock_path, timeout=30, poll_interval=0.0005): if occupied_path.exists(): raise SystemExit("two holders entered the critical section at once") occupied_path.write_text("held") count = int(counter_path.read_text()) if counter_path.exists() else 0 counter_path.write_text(str(count + 1)) occupied_path.unlink() """ pytestmark = pytest.mark.requires_hard_links @pytest.mark.timeout(_WORKER_DEADLINE + 60) def test_strict_soft_eight_process_contention_has_no_overlap(tmp_path: Path) -> None: lock_path = tmp_path / "resource.lock" start_path = tmp_path / "start" counter_path = tmp_path / "counter" occupied_path = tmp_path / "occupied" processes = [ subprocess.Popen( [ sys.executable, "-c", _WORKER, str(lock_path), str(start_path), str(counter_path), str(occupied_path), str(_ACQUISITIONS_PER_PROCESS), ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=sys.platform != "win32", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0, ) for _ in range(_PROCESS_COUNT) ] start_path.touch() deadline = time.monotonic() + _WORKER_DEADLINE outputs: list[tuple[str, str]] = [] try: outputs.extend(process.communicate(timeout=max(0.1, deadline - time.monotonic())) for process in processes) except subprocess.TimeoutExpired: # pragma: no cover - test failure cleanup _kill_processes(processes) pytest.fail(f"strict soft-lock stress workers exceeded {_WORKER_DEADLINE} seconds") recorded = int(counter_path.read_text()) if counter_path.exists() else 0 assert ([process.returncode for process in processes], recorded) == ( [0] * _PROCESS_COUNT, _PROCESS_COUNT * _ACQUISITIONS_PER_PROCESS, ), outputs def _kill_processes(processes: list[subprocess.Popen[str]]) -> None: # pragma: no cover - test failure cleanup for process in processes: if process.poll() is None: if sys.platform == "win32": process.kill() else: os.killpg(process.pid, signal.SIGKILL) for process in processes: process.communicate() filelock-3.32.2/tests/test_subclass_options.py0000644000000000000000000003052213615410400016460 0ustar00from __future__ import annotations import asyncio import gc from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast from weakref import ref import pytest from filelock import BaseAsyncFileLock, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy from tests.capability_marks import NEEDS_CLASS_COLLECTION if TYPE_CHECKING: from collections.abc import Callable from concurrent.futures import Executor from pathlib import Path from typing_extensions import Unpack @pytest.mark.parametrize( ("option", "value"), [ pytest.param("timeout", 1, id="timeout"), pytest.param("mode", 0o600, id="mode"), pytest.param("thread_local", False, id="thread-local"), pytest.param("blocking", False, id="blocking"), pytest.param("is_singleton", True, id="singleton"), pytest.param("poll_interval", 0.1, id="poll-interval"), pytest.param("lifetime", 1, id="lifetime"), pytest.param("context_error_policy", "group", id="context-error-policy"), pytest.param("close_error_policy", "raise", id="close-error-policy"), pytest.param("fallback_to_soft", False, id="fallback-to-soft"), pytest.param("preserve_lock_file", True, id="preserve-lock-file"), pytest.param("on_acquired", list[int]().append, id="on-acquired"), ], ) def test_narrow_sync_subclass_rejects_non_default_option( option: str, value: float | str | Callable[[int], None], tmp_path: Path, ) -> None: with pytest.raises(TypeError, match=option): _sync_constructor()( str(tmp_path / "lock"), **cast("_FileLockOptions", {option: value}), ) def test_narrow_sync_subclass_rejects_unknown_option(tmp_path: Path) -> None: with pytest.raises(TypeError, match="unknown"): _unknown_constructor()(str(tmp_path / "lock"), unknown=True) @pytest.mark.parametrize( "option", [ pytest.param("thread_local", id="thread-local"), pytest.param("loop", id="loop"), pytest.param("run_in_executor", id="run-in-executor"), pytest.param("executor", id="executor"), ], ) def test_narrow_async_subclass_rejects_non_default_option( option: Literal["thread_local", "loop", "run_in_executor", "executor"], tmp_path: Path, ) -> None: loop = asyncio.new_event_loop() try: with ThreadPoolExecutor(max_workers=1) as executor, pytest.raises(TypeError, match=option): _async_constructor()( str(tmp_path / "lock"), **_unsupported_async_options(option, loop, executor), ) finally: loop.close() @pytest.mark.parametrize( "kind", [pytest.param("sync", id="sync"), pytest.param("async", id="async")], ) def test_narrow_subclass_accepts_explicit_base_defaults( kind: Literal["sync", "async"], tmp_path: Path, ) -> None: lock_path = str(tmp_path / "lock") assert _build_with_explicit_defaults(kind, lock_path).lock_file == lock_path def test_narrow_subclass_accepts_named_option(tmp_path: Path) -> None: assert _NamedFileLock(str(tmp_path / "lock"), marker=7).marker == 7 def test_sync_subclass_forwards_kwargs(tmp_path: Path) -> None: hook: Callable[[int], None] = list[int]().append lock = _ForwardingFileLock( str(tmp_path / "lock"), timeout=2, mode=0o600, thread_local=False, blocking=False, is_singleton=True, poll_interval=0.1, lifetime=3, context_error_policy="group", close_error_policy="suppress", fallback_to_soft=False, preserve_lock_file=True, on_acquired=hook, ) assert { "timeout": lock.timeout, "mode": lock.mode, "thread_local": lock.is_thread_local(), "blocking": lock.blocking, "is_singleton": lock.is_singleton, "poll_interval": lock.poll_interval, "lifetime": lock.lifetime, "context_error_policy": lock.context_error_policy, "close_error_policy": lock.close_error_policy, "fallback_to_soft": lock.fallback_to_soft, "preserve_lock_file": lock.preserve_lock_file, "on_acquired": lock.on_acquired, } == { "timeout": 2, "mode": 0o600, "thread_local": False, "blocking": False, "is_singleton": True, "poll_interval": 0.1, "lifetime": 3, "context_error_policy": "group", "close_error_policy": "suppress", "fallback_to_soft": False, "preserve_lock_file": True, "on_acquired": hook, } def test_async_subclass_forwards_kwargs(tmp_path: Path) -> None: hook: Callable[[int], None] = list[int]().append loop = asyncio.new_event_loop() try: with ThreadPoolExecutor(max_workers=1) as executor: lock = _ForwardingAsyncFileLock( str(tmp_path / "lock"), timeout=2, mode=0o600, thread_local=True, blocking=False, is_singleton=True, poll_interval=0.1, lifetime=3, context_error_policy="group", close_error_policy="suppress", fallback_to_soft=False, preserve_lock_file=True, on_acquired=hook, loop=loop, run_in_executor=False, executor=executor, ) assert { "timeout": lock.timeout, "mode": lock.mode, "thread_local": lock.is_thread_local(), "blocking": lock.blocking, "is_singleton": lock.is_singleton, "poll_interval": lock.poll_interval, "lifetime": lock.lifetime, "context_error_policy": lock.context_error_policy, "close_error_policy": lock.close_error_policy, "fallback_to_soft": lock.fallback_to_soft, "preserve_lock_file": lock.preserve_lock_file, "on_acquired": lock.on_acquired, "loop": lock.loop, "run_in_executor": lock.run_in_executor, "executor": lock.executor, } == { "timeout": 2, "mode": 0o600, "thread_local": True, "blocking": False, "is_singleton": True, "poll_interval": 0.1, "lifetime": 3, "context_error_policy": "group", "close_error_policy": "suppress", "fallback_to_soft": False, "preserve_lock_file": True, "on_acquired": hook, "loop": loop, "run_in_executor": False, "executor": executor, } finally: loop.close() @pytest.mark.parametrize( "option", [pytest.param("hook", id="hook"), pytest.param("policy", id="policy")], ) def test_forwarding_subclass_singleton_rejects_changed_option( option: Literal["hook", "policy"], tmp_path: Path, ) -> None: hook: Callable[[int], None] = list[int]().append lock_path = str(tmp_path / "lock") first = _ForwardingFileLock(lock_path, is_singleton=True, on_acquired=hook) with pytest.raises(ValueError, match="on_acquired" if option == "hook" else "context_error_policy"): _change_singleton_option(option, lock_path, hook) assert first.on_acquired is hook @NEEDS_CLASS_COLLECTION def test_constructor_model_does_not_retain_dynamic_subclass(tmp_path: Path) -> None: lock_type = _dynamic_constructor() class_ref = ref(lock_type) lock_type(str(tmp_path / "lock")) del lock_type gc.collect() assert class_ref() is None def _sync_constructor() -> type[BaseFileLock]: return cast("type[BaseFileLock]", _NarrowFileLock) def _async_constructor() -> type[BaseAsyncFileLock]: return cast("type[BaseAsyncFileLock]", _NarrowAsyncFileLock) def _unknown_constructor() -> _UnknownFileLockConstructor: return cast("_UnknownFileLockConstructor", _NarrowFileLock) def _unsupported_async_options( option: Literal["thread_local", "loop", "run_in_executor", "executor"], loop: asyncio.AbstractEventLoop, executor: Executor, ) -> _AsyncFileLockOptions: if option == "thread_local": return {"thread_local": True, "run_in_executor": False} if option == "loop": return {"loop": loop} if option == "run_in_executor": return {"run_in_executor": False} return {"executor": executor} def _build_with_explicit_defaults(kind: Literal["sync", "async"], lock_path: str) -> BaseFileLock: if kind == "sync": return _sync_constructor()( lock_path, timeout=-1, mode=-1, thread_local=True, blocking=True, is_singleton=False, poll_interval=0.05, lifetime=None, context_error_policy="chain", close_error_policy="default", fallback_to_soft=True, preserve_lock_file=False, on_acquired=None, ) return _async_constructor()( lock_path, timeout=-1, mode=-1, thread_local=False, blocking=True, is_singleton=False, poll_interval=0.05, lifetime=None, context_error_policy="chain", close_error_policy="default", fallback_to_soft=True, preserve_lock_file=False, on_acquired=None, loop=None, run_in_executor=True, executor=None, ) def _change_singleton_option( option: Literal["hook", "policy"], lock_path: str, hook: Callable[[int], None], ) -> BaseFileLock: if option == "hook": return _ForwardingFileLock(lock_path, is_singleton=True, on_acquired=list[int]().append) return _ForwardingFileLock( lock_path, is_singleton=True, context_error_policy="group", on_acquired=hook, ) def _dynamic_constructor() -> type[BaseFileLock]: class DynamicFileLock(BaseFileLock): def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError return DynamicFileLock class _NarrowFileLock(BaseFileLock): _lifetime_supported = True def __init__(self, lock_file: str) -> None: super().__init__(lock_file) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _NamedFileLock(BaseFileLock): def __init__(self, lock_file: str, *, marker: int = 0) -> None: super().__init__(lock_file) self.marker = marker def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _NarrowAsyncFileLock(BaseAsyncFileLock): _lifetime_supported = True def __init__(self, lock_file: str) -> None: super().__init__(lock_file) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _ForwardingFileLock(BaseFileLock): _lifetime_supported = True def __init__(self, lock_file: str, **kwargs: Unpack[_FileLockOptions]) -> None: super().__init__(lock_file, **kwargs) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _ForwardingAsyncFileLock(BaseAsyncFileLock): _lifetime_supported = True def __init__(self, lock_file: str, **kwargs: Unpack[_AsyncFileLockOptions]) -> None: super().__init__(lock_file, **kwargs) def _acquire(self) -> None: raise NotImplementedError def _release(self) -> None: raise NotImplementedError class _FileLockOptions(TypedDict, total=False): timeout: float mode: int thread_local: bool blocking: bool is_singleton: bool poll_interval: float lifetime: float | None context_error_policy: ContextErrorPolicy close_error_policy: CloseErrorPolicy fallback_to_soft: bool preserve_lock_file: bool on_acquired: Callable[[int], None] | None class _AsyncFileLockOptions(_FileLockOptions, total=False): loop: asyncio.AbstractEventLoop | None run_in_executor: bool executor: Executor | None class _UnknownFileLockConstructor(Protocol): def __call__(self, lock_file: str, *, unknown: bool) -> BaseFileLock: ... filelock-3.32.2/tests/test_unix_fallback.py0000644000000000000000000001407313615410400015673 0ustar00from __future__ import annotations import os import socket import subprocess # ruff:ignore[suspicious-subprocess-import] # a clean interpreter isolates the blocked fcntl import import sys from errno import EIO, ENOSYS from textwrap import dedent from typing import TYPE_CHECKING, Final import pytest from filelock import SoftFileLock, UnixFileLock from filelock._identity import process_start_token from tests.capability_marks import NEEDS_FCNTL if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock from pytest_mock import MockerFixture @NEEDS_FCNTL def test_import_without_fcntl_uses_soft_aliases_and_descriptor_errors() -> None: # pragma: needs fcntl script: Final[str] = dedent( """ import json import sys import warnings from collections.abc import Callable from errno import ENOSYS sys.modules["fcntl"] = None with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") import filelock def operation_errno(operation: Callable[[int], bool | None]) -> int | None: try: operation(-1) except OSError as exception: return exception.errno return None print(json.dumps({ "aliases": [ filelock.FileLock is filelock.SoftFileLock, filelock.AsyncFileLock is filelock.AsyncSoftFileLock, ], "errors": [ operation_errno(filelock.lock_descriptor) == ENOSYS, operation_errno(filelock.unlock_descriptor) == ENOSYS, ], "warnings": [str(item.message) for item in caught], }, sort_keys=True)) """, ) result: Final[subprocess.CompletedProcess[str]] = subprocess.run( [sys.executable, "-c", script], check=True, capture_output=True, text=True ) assert (result.stdout, result.stderr) == ( '{"aliases": [true, true], "errors": [true, true], "warnings": ["only soft file lock is available"]}\n', "", ) @NEEDS_FCNTL @pytest.mark.usefixtures("unsupported_flock") def test_fallback_emits_warning(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with pytest.warns(UserWarning, match="flock not supported on this filesystem, falling back to SoftFileLock"): lock.acquire() lock.release() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_swaps_to_soft(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with lock: assert lock.is_locked assert isinstance(lock, SoftFileLock) @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_writes_pid_and_hostname(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" with UnixFileLock(lock_path): lines = lock_path.read_text(encoding="utf-8").splitlines() expected = [str(os.getpid()), socket.gethostname()] if (token := process_start_token(os.getpid())) is not None: # pragma: no branch # CI always exposes a start time expected.append(str(token)) assert lines == expected @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_release_unlinks_file(tmp_path: Path) -> None: # pragma: needs fcntl lock_path = tmp_path / "test.lock" lock = UnixFileLock(lock_path) lock.acquire() assert lock_path.exists() lock.release() assert not lock_path.exists() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") # pragma: needs fcntl def test_fallback_subsequent_acquire_skips_flock(tmp_path: Path, unsupported_flock: MagicMock) -> None: lock = UnixFileLock(tmp_path / "test.lock") lock.acquire() lock.release() unsupported_flock.reset_mock() lock.acquire() lock.release() unsupported_flock.assert_not_called() @NEEDS_FCNTL @pytest.mark.filterwarnings("ignore:flock not supported on this filesystem:UserWarning") @pytest.mark.usefixtures("unsupported_flock") def test_fallback_reentrant_locking(tmp_path: Path) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") with lock: with lock: assert lock.is_locked assert lock.is_locked assert not lock.is_locked @NEEDS_FCNTL def test_release_suppresses_eio_on_close(tmp_path: Path, mocker: MockerFixture) -> None: # pragma: needs fcntl lock = UnixFileLock(tmp_path / "test.lock") lock.acquire() real_close = os.close # capture before the patch so the fd still closes for real fd_to_fail = lock._context.lock_file_fd # _release() nulls this before closing, so read it now def _close_eio(fd: int) -> None: real_close(fd) if fd == fd_to_fail: # pragma: no branch # only the lock's own descriptor closes while the patch is live raise OSError(EIO, "Input/output error") mocker.patch("filelock._unix.os.close", side_effect=_close_eio) lock.release() assert not lock.is_locked @NEEDS_FCNTL # pragma: needs fcntl def test_acquire_flock_error_clears_pending_descriptor(tmp_path: Path, mocker: MockerFixture) -> None: lock = UnixFileLock(tmp_path / "test.lock") mocker.patch( "filelock._unix.fcntl.flock", side_effect=[OSError(EIO, "Input/output error"), None, None], ) with pytest.raises(OSError, match="Input/output error"): lock.acquire(timeout=0) assert not lock.is_locked lock.acquire(timeout=0) assert lock.is_locked lock.release() assert not lock.is_locked @pytest.fixture def unsupported_flock(mocker: MockerFixture) -> MagicMock: # pragma: needs fcntl return mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "Function not implemented")) filelock-3.32.2/tests/test_util.py0000644000000000000000000001617413615410400014052 0ustar00from __future__ import annotations import os import stat import sys from typing import TYPE_CHECKING, Final import pytest from filelock._util import break_lock_file, raise_on_not_writable_file if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture _SKIP_AS_ROOT: Final[pytest.MarkDecorator] = pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="root can write a 0o444 file", ) def test_break_lock_file_unlinks_unchanged_file(tmp_path: Path) -> None: lock = tmp_path / "test.lock" lock.write_text("stale", encoding="utf-8") st = os.lstat(lock) break_lock_file(str(lock), st.st_mtime, st.st_ino) assert not lock.exists() assert list(tmp_path.glob("test.lock.break.*")) == [] def test_break_lock_file_preserves_file_when_mtime_advanced(tmp_path: Path) -> None: lock = tmp_path / "test.lock" lock.write_text("live", encoding="utf-8") # An mtime_before older than the file's real mtime models a peer recreating the lock after our stale read. # break_lock_file renames the live file aside but must not unlink it, so we never end with two live holders. break_lock_file(str(lock), mtime_before=0.0, ino_before=os.lstat(lock).st_ino) assert not lock.exists() leftover = list(tmp_path.glob("test.lock.break.*")) assert len(leftover) == 1 assert leftover[0].read_text(encoding="utf-8") == "live" def test_break_lock_file_preserves_file_when_inode_changed(tmp_path: Path) -> None: lock = tmp_path / "test.lock" lock.write_text("stale", encoding="utf-8") st = os.lstat(lock) # Model a coarse-granularity filesystem (NFS, FAT) where a peer broke and recreated the lock with a new inode # but the same mtime second. Creating the replacement while the original still exists guarantees a fresh inode. other = tmp_path / "recreated" other.write_text("live", encoding="utf-8") os.utime(other, ns=(st.st_atime_ns, st.st_mtime_ns)) assert os.lstat(other).st_ino != st.st_ino other.replace(lock) break_lock_file(str(lock), st.st_mtime, st.st_ino) leftover = list(tmp_path.glob("test.lock.break.*")) assert len(leftover) == 1 assert leftover[0].read_text(encoding="utf-8") == "live" def test_break_lock_file_aborts_if_break_path_vanishes(tmp_path: Path, mocker: MockerFixture) -> None: lock = tmp_path / "test.lock" lock.write_text("x", encoding="utf-8") ino = os.lstat(lock).st_ino mocker.patch("filelock._util.os.lstat", side_effect=FileNotFoundError) break_lock_file(str(lock), 0.0, ino) assert not lock.exists() assert len(list(tmp_path.glob("test.lock.break.*"))) == 1 def test_break_lock_file_missing_source_raises(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): break_lock_file(str(tmp_path / "nope.lock"), 0.0, 0) def test_break_lock_file_break_path_not_targetable_by_a_peer(tmp_path: Path, mocker: MockerFixture) -> None: lock = tmp_path / "test.lock" lock.write_text("stale", encoding="utf-8") st = os.lstat(lock) # A second breaker in the same process independently computes this name (no random token). If break_lock_file # used it too, the peer could rename a freshly recreated live lock onto our break path in the window between the # re-verify lstat and the unlink, and we would delete a live lock the inode check just approved. predictable = tmp_path / f"test.lock.break.{os.getpid()}" real_lstat = os.lstat def lstat_hook(path: str) -> os.stat_result: result = real_lstat(path) # break_lock_file lstats the break path exactly once, so this guard only ever runs its body (once). if ".break." in path and not predictable.exists(): # pragma: no branch # the peer recreates a live lock lock.write_text("live", encoding="utf-8") lock.rename(predictable) return result mocker.patch("filelock._util.os.lstat", side_effect=lstat_hook) break_lock_file(str(lock), st.st_mtime, st.st_ino) assert predictable.read_text(encoding="utf-8") == "live" @pytest.mark.skipif(sys.platform == "win32", reason="symlink-to-dir raises IsADirectoryError only on Unix") def test_raise_on_not_writable_file_does_not_follow_symlink_to_dir(tmp_path: Path) -> None: # pragma: win32 no cover target = tmp_path / "targetdir" target.mkdir() link = tmp_path / "my.lock" link.symlink_to(target) # Following the symlink would see a directory and raise IsADirectoryError; lstat sees the link itself. raise_on_not_writable_file(str(link)) assert stat.S_ISLNK(os.lstat(link).st_mode) @pytest.mark.skipif(sys.platform == "win32", reason="symlink + 0o444 semantics differ on Windows") @_SKIP_AS_ROOT # pragma: win32 no cover def test_raise_on_not_writable_file_does_not_follow_symlink_to_readonly(tmp_path: Path) -> None: target = tmp_path / "readonly" target.write_text("x", encoding="utf-8") target.chmod(0o444) link = tmp_path / "my.lock" link.symlink_to(target) # Following the symlink would see a read-only file and raise PermissionError; the link itself is writable. raise_on_not_writable_file(str(link)) @pytest.mark.skipif(sys.platform == "win32", reason="real dir raises PermissionError on Windows") def test_raise_on_not_writable_file_still_rejects_real_directory(tmp_path: Path) -> None: # pragma: win32 no cover path = tmp_path / "a_dir" path.mkdir() with pytest.raises(IsADirectoryError): # pragma: win32 no cover raise_on_not_writable_file(str(path)) @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have read only files in the same way") @_SKIP_AS_ROOT def test_raise_on_not_writable_file_still_rejects_readonly_file(tmp_path: Path) -> None: # pragma: win32 no cover path = tmp_path / "ro.lock" path.write_text("x", encoding="utf-8") path.chmod(0o444) try: # pragma: win32 no cover with pytest.raises(PermissionError): # pragma: win32 no cover raise_on_not_writable_file(str(path)) finally: path.chmod(0o644) # Writability follows the mode bits, so a read-only file is rejected whatever its mtime. The far-future row pins that # a later patch cannot narrow the check back to a single mtime range. The check reads the mode rather than probing # real access, so it holds for root too and needs no euid skip. @pytest.mark.parametrize("mtime", [0, 2_000_000_000], ids=["mtime-zero", "mtime-future"]) def test_raise_on_not_writable_file_rejects_readonly_file_any_mtime(tmp_path: Path, mtime: int) -> None: path = tmp_path / "ro.lock" path.write_text("x", encoding="utf-8") path.chmod(0o444) try: os.utime(path, (mtime, mtime)) with pytest.raises(PermissionError): raise_on_not_writable_file(str(path)) finally: path.chmod(0o644) @pytest.mark.skipif(sys.platform == "win32", reason="a real directory raises PermissionError on Windows") def test_raise_on_not_writable_file_rejects_directory_with_mtime_zero(tmp_path: Path) -> None: # pragma: win32 no cover path = tmp_path / "a_dir" path.mkdir() os.utime(path, (0, 0)) with pytest.raises(IsADirectoryError): # pragma: win32 no cover raise_on_not_writable_file(str(path)) filelock-3.32.2/tests/test_virtualenv.py0000644000000000000000000000072313615410400015265 0ustar00from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest from virtualenv import cli_run if TYPE_CHECKING: from pathlib import Path @pytest.mark.skipif( hasattr(sys, "pypy_version_info"), reason="PyPy segfaults in SQLite Cursor.__del__._reset when GC runs during virtualenv imports", ) def test_virtualenv(tmp_path: Path) -> None: cli_run([str(tmp_path), "--no-pip", "--no-setuptools", "--no-periodic-update"]) filelock-3.32.2/tests/soft_rw/test_soft_rw_async.py0000644000000000000000000002507713615410400017442 0ustar00from __future__ import annotations import asyncio import os import threading from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Literal import pytest from filelock import Timeout from filelock._soft_rw import AsyncSoftReadWriteLock, SoftReadWriteLock from tests.capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION if TYPE_CHECKING: from collections.abc import Callable, Generator from concurrent.futures import Executor from pathlib import Path from pytest_mock import MockerFixture @pytest.fixture(autouse=True) def _clear_singletons() -> Generator[None]: SoftReadWriteLock._instances.clear() yield for lock in filter(None, (ref() for ref in list(SoftReadWriteLock._instances.valuerefs()))): lock.close() SoftReadWriteLock._instances.clear() def _make( tmp_path: Path, name: str = "foo.lock", *, timeout: float = -1, blocking: bool = True, heartbeat_interval: float = 0.1, stale_threshold: float = 0.5, poll_interval: float = 0.02, loop: asyncio.AbstractEventLoop | None = None, executor: Executor | None = None, ) -> AsyncSoftReadWriteLock: return AsyncSoftReadWriteLock( str(tmp_path / name), timeout=timeout, blocking=blocking, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, loop=loop, executor=executor, ) class _Gate: """Pause one call inside the executor thread so a cancellation lands after the backend work started.""" def __init__(self, mocker: MockerFixture, name: str, *, fail_with: BaseException | None = None) -> None: self.started = asyncio.Event() # Set once the gated call returns, letting a test distinguish the executor finishing from the caller giving up. self.finished = threading.Event() self._resume = threading.Event() self._loop = asyncio.get_running_loop() self._real: Callable[..., object] = getattr(SoftReadWriteLock, name) self._pending = True mocker.patch.object(SoftReadWriteLock, name, autospec=True, side_effect=self._call(fail_with)) def _call(self, fail_with: BaseException | None) -> Callable[..., object]: def call(lock: SoftReadWriteLock, *args: object, **kwargs: object) -> object: if not self._pending: return self._real(lock, *args, **kwargs) self._pending = False self._loop.call_soon_threadsafe(self.started.set) assert self._resume.wait(timeout=5) try: if fail_with is not None: raise fail_with return self._real(lock, *args, **kwargs) finally: self.finished.set() return call def resume(self) -> None: self._resume.set() def _markers(tmp_path: Path) -> list[str]: readers = tmp_path / "foo.lock.readers" reader_names = sorted(entry.name for entry in readers.iterdir()) if readers.is_dir() else [] return ([""] if (tmp_path / "foo.lock.write").exists() else []) + reader_names @pytest.mark.asyncio async def test_async_write_lock_context_manager(tmp_path: Path) -> None: lock = _make(tmp_path) try: async with lock.write_lock(timeout=2): pass finally: await lock.close() @pytest.mark.asyncio async def test_async_read_lock_context_manager(tmp_path: Path) -> None: lock = _make(tmp_path) try: async with lock.read_lock(timeout=2): pass finally: await lock.close() @pytest.mark.asyncio async def test_async_exposes_configuration(tmp_path: Path) -> None: lock = _make(tmp_path, timeout=5, blocking=False) try: assert lock.lock_file.endswith("foo.lock") assert lock.timeout == 5 assert lock.blocking is False assert lock.loop is None assert lock.executor is None finally: await lock.close() @pytest.mark.asyncio async def test_async_raw_acquire_release_round_trip(tmp_path: Path) -> None: lock = _make(tmp_path) try: proxy = await lock.acquire_write(timeout=2) async with proxy: pass proxy = await lock.acquire_read(timeout=2) async with proxy: pass finally: await lock.close() @pytest.mark.asyncio async def test_async_custom_loop_and_executor(tmp_path: Path) -> None: with ThreadPoolExecutor(max_workers=1) as executor: loop = asyncio.get_running_loop() lock = _make(tmp_path, loop=loop, executor=executor) try: assert lock.loop is loop assert lock.executor is executor async with lock.write_lock(timeout=2): pass finally: await lock.close() @pytest.mark.asyncio async def test_async_release_force(tmp_path: Path) -> None: lock = _make(tmp_path) try: await lock.acquire_read(timeout=2) await lock.release(force=True) finally: await lock.close() @pytest.mark.asyncio async def test_async_release_and_close_skip_when_pid_differs(tmp_path: Path, mocker: MockerFixture) -> None: # A pid that no longer matches the creator's must leave the parent's still-held lock untouched. lock = _make(tmp_path) await lock.acquire_write(timeout=2) try: mocker.patch("filelock._soft_rw._async.os.getpid", return_value=os.getpid() + 1) await lock.release(force=True) await lock.close() mocker.stopall() await lock.release() finally: mocker.stopall() await lock.close() @pytest.mark.asyncio async def test_async_leaked_singleton_is_closed_on_teardown(tmp_path: Path) -> None: # A live heartbeat thread keeps the singleton reachable, so the autouse teardown finds and closes it. path = tmp_path / "singleton.lock" lock = AsyncSoftReadWriteLock(str(path), heartbeat_interval=0.5, stale_threshold=1.5, poll_interval=0.02) await lock.acquire_write(timeout=2) assert path.with_name("singleton.lock.write").exists() @pytest.mark.asyncio async def test_async_writer_times_out_behind_reader(tmp_path: Path) -> None: reader = _make(tmp_path) await reader.acquire_read(timeout=2) try: writer = _make(tmp_path) try: with pytest.raises(Timeout): await writer.acquire_write(timeout=0.3) finally: await writer.close() finally: await reader.release() await reader.close() @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_hands_the_claim_back( tmp_path: Path, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: gate = _Gate(mocker, f"acquire_{mode}") lock = _make(tmp_path) acquire = lock.acquire_read if mode == "read" else lock.acquire_write task = asyncio.create_task(acquire(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(asyncio.CancelledError): await task assert gate.finished.wait(timeout=5), "the executor never finished the acquire it was already running" assert lock._lock._hold is None assert _markers(tmp_path) == [] contender = SoftReadWriteLock(str(tmp_path / "foo.lock"), is_singleton=False, poll_interval=0.02) try: with contender.write_lock(timeout=5): pass finally: contender.close() await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_surfaces_a_failed_acquire(tmp_path: Path, mocker: MockerFixture) -> None: acquire_error = Timeout(str(tmp_path / "foo.lock")) gate = _Gate(mocker, "acquire_write", fail_with=acquire_error) lock = _make(tmp_path) task = asyncio.create_task(lock.acquire_write(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(Timeout) as info: await task assert info.value is acquire_error assert isinstance(acquire_error.__context__, asyncio.CancelledError) assert lock._lock._hold is None assert _markers(tmp_path) == [] await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_acquire_cancellation_surfaces_a_failed_release(tmp_path: Path, mocker: MockerFixture) -> None: gate = _Gate(mocker, "acquire_write") release_error = RuntimeError("release failed") mocker.patch.object(SoftReadWriteLock, "release", side_effect=release_error) lock = _make(tmp_path) task = asyncio.create_task(lock.acquire_write(timeout=5)) await gate.started.wait() task.cancel("cancel acquire") gate.resume() with pytest.raises(RuntimeError, match="release failed") as info: await task assert info.value is release_error assert isinstance(release_error.__context__, asyncio.CancelledError) mocker.stopall() await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_release_cancellation_drains_the_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = _make(tmp_path) await lock.acquire_write(timeout=5) gate = _Gate(mocker, "release") task = asyncio.create_task(lock.release()) await gate.started.wait() task.cancel("cancel release") # Resume only after the cancellation has had a chance to propagate, so the cancellation reaching the caller # before the release finished is a failure rather than a coin flip. asyncio.get_running_loop().call_later(0.05, gate.resume) with pytest.raises(asyncio.CancelledError): await task assert gate.finished.is_set(), "the cancellation surfaced while the release was still running" assert lock._lock._hold is None assert _markers(tmp_path) == [] await lock.close() @pytest.mark.asyncio @XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_async_release_cancellation_surfaces_a_failed_release(tmp_path: Path, mocker: MockerFixture) -> None: lock = _make(tmp_path) await lock.acquire_write(timeout=5) release_error = RuntimeError("release failed") gate = _Gate(mocker, "release", fail_with=release_error) task = asyncio.create_task(lock.release()) await gate.started.wait() task.cancel("cancel release") gate.resume() with pytest.raises(RuntimeError, match="release failed") as info: await task assert info.value is release_error assert isinstance(release_error.__context__, asyncio.CancelledError) mocker.stopall() await lock.close() filelock-3.32.2/tests/soft_rw/test_soft_rw_sync.py0000644000000000000000000013406513615410400017277 0ustar00from __future__ import annotations import multiprocessing as mp import os import signal import socket import stat import sys import threading import time from contextlib import suppress from errno import EIO, ENOENT from multiprocessing import Event, Process from pathlib import Path from typing import TYPE_CHECKING, Final, Literal import pytest from capabilities import CAPABILITIES from filelock import Timeout from filelock import _util as util_mod from filelock._soft_rw import SoftReadWriteLock from filelock._soft_rw import _sync as sync_mod from tests.capability_marks import NEEDS_FILE_MODE, NEEDS_FORK, NEEDS_POSIX_SIGNALS, SKIP_ON_UNRELIABLE_PROCESS_SYNC from tests.process_helpers import cleanup_processes if TYPE_CHECKING: from collections.abc import Callable, Generator from multiprocessing.synchronize import Event as EventType from pytest_mock import MockerFixture _OWNER_READ_WRITE: Final[int] = 0o600 # Bounds how long a spawned process or thread may take to reach the lock, not how fast it must be: an interpreter # that starts slowly under a loaded suite is not a locking failure. The short negative waits below are deliberate, # since those assert a contender stays blocked and have to stay brief. _PROCESS_DEADLINE: Final[int] = 30 @pytest.fixture(autouse=True) def _clear_singletons() -> Generator[None]: SoftReadWriteLock._instances.clear() yield for lock in filter(None, (ref() for ref in list(SoftReadWriteLock._instances.valuerefs()))): lock.close() SoftReadWriteLock._instances.clear() @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test.lock") def test_rejects_non_positive_heartbeat_interval(lock_file: str) -> None: with pytest.raises(ValueError, match="heartbeat_interval must be positive"): SoftReadWriteLock(lock_file, heartbeat_interval=0, is_singleton=False) def test_rejects_stale_threshold_not_greater_than_heartbeat(lock_file: str) -> None: with pytest.raises(ValueError, match="stale_threshold must exceed"): SoftReadWriteLock(lock_file, heartbeat_interval=10, stale_threshold=5, is_singleton=False) def test_rejects_non_positive_poll_interval(lock_file: str) -> None: with pytest.raises(ValueError, match="poll_interval must be positive"): SoftReadWriteLock(lock_file, poll_interval=0, is_singleton=False) def test_public_attributes(lock_file: str) -> None: lock = SoftReadWriteLock( lock_file, timeout=5, blocking=False, heartbeat_interval=10, stale_threshold=45, poll_interval=0.5, is_singleton=False, ) try: assert lock.lock_file == lock_file assert lock.timeout == 5 assert lock.blocking is False assert lock.heartbeat_interval == 10 assert lock.stale_threshold == 45 assert lock.poll_interval == pytest.approx(0.5) finally: lock.close() def test_default_stale_threshold_is_triple_heartbeat(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file, heartbeat_interval=12, is_singleton=False) try: assert lock.stale_threshold == 36 finally: lock.close() def test_singleton_returns_same_instance(lock_file: str) -> None: first = SoftReadWriteLock(lock_file) second = SoftReadWriteLock(lock_file) try: assert first is second finally: first.close() def test_non_singleton_returns_distinct_instances(lock_file: str) -> None: first = SoftReadWriteLock(lock_file, is_singleton=False) second = SoftReadWriteLock(lock_file, is_singleton=False) try: assert first is not second finally: first.close() second.close() def test_singleton_mismatch_raises(lock_file: str) -> None: first = SoftReadWriteLock(lock_file, timeout=5) try: with pytest.raises(ValueError, match="cannot be changed"): SoftReadWriteLock(lock_file, timeout=10) finally: first.close() def test_get_lock_returns_singleton(lock_file: str) -> None: first = SoftReadWriteLock.get_lock(lock_file) second = SoftReadWriteLock.get_lock(lock_file) try: assert first is second finally: first.close() def test_leaked_acquired_singleton_is_closed_on_teardown(lock_file: str) -> None: # A live heartbeat thread keeps the singleton reachable, so the autouse teardown finds and closes it. lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.5) lock.acquire_write(timeout=2) assert Path(f"{lock_file}.write").exists() def test_reentrant_read_holds_and_releases(lock_file: str) -> None: lock = _make_lock(lock_file) try: with lock.read_lock(timeout=2), lock.read_lock(timeout=2): pass with lock.read_lock(timeout=2): pass finally: lock.close() def test_reentrant_write_holds_and_releases(lock_file: str) -> None: lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2), lock.write_lock(timeout=2): assert Path(f"{lock_file}.write").exists() assert not Path(f"{lock_file}.write").exists() finally: lock.close() def test_upgrade_from_read_to_write_raises(lock_file: str) -> None: lock = _make_lock(lock_file) try: with lock.read_lock(timeout=2), pytest.raises(RuntimeError, match="upgrade not allowed"): lock.acquire_write(timeout=1) finally: lock.close() def test_downgrade_from_write_to_read_raises(lock_file: str) -> None: lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2), pytest.raises(RuntimeError, match="downgrade not allowed"): lock.acquire_read(timeout=1) finally: lock.close() def test_write_lock_is_thread_pinned(lock_file: str) -> None: lock = _make_lock(lock_file) errors: list[BaseException] = [] lock.acquire_write(timeout=2) def other() -> None: try: lock.acquire_write(timeout=1, blocking=False) except BaseException as exc: errors.append(exc) thread = threading.Thread(target=other) thread.start() thread.join() lock.release() lock.close() assert len(errors) == 1 assert isinstance(errors[0], (RuntimeError, Timeout)) def test_blocking_acquire_without_timeout_waits_for_release(lock_file: str) -> None: # Without a deadline the poll loop sleeps a full interval each round rather than clamping to a budget. holder = _make_lock(lock_file) holder.acquire_write(timeout=2) acquired = threading.Event() def waiter() -> None: contender = _make_lock(lock_file) try: contender.acquire_read(timeout=-1) acquired.set() contender.release() finally: contender.close() thread = threading.Thread(target=waiter) thread.start() try: assert not acquired.wait(timeout=0.2) holder.release() assert acquired.wait(timeout=_PROCESS_DEADLINE) finally: thread.join(timeout=_PROCESS_DEADLINE) holder.close() def test_release_without_hold_raises(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) try: with pytest.raises(RuntimeError, match="not held"): lock.release() finally: lock.close() def test_release_force_without_hold_is_noop(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) try: lock.release(force=True) finally: lock.close() def test_release_force_on_reentrant_lock_drops_all(lock_file: str) -> None: lock = _make_lock(lock_file) try: lock.acquire_read(timeout=2) lock.acquire_read(timeout=2) lock.release(force=True) with lock.write_lock(timeout=2): pass finally: lock.close() def test_close_is_idempotent(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) lock.close() lock.close() def test_acquire_on_closed_raises(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file) lock.close() with pytest.raises(RuntimeError, match="has been closed"): lock.acquire_read(timeout=1) with pytest.raises(RuntimeError, match="has been closed"): lock.acquire_write(timeout=1) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_multiple_readers_can_hold_simultaneously(lock_file: str) -> None: r1, r2, release = Event(), Event(), Event() p1 = Process(target=_worker, args=(lock_file, "read", r1, release)) p2 = Process(target=_worker, args=(lock_file, "read", r2, release)) with cleanup_processes([p1, p2]): p1.start() p2.start() assert r1.wait(timeout=_PROCESS_DEADLINE) assert r2.wait(timeout=_PROCESS_DEADLINE) release.set() p1.join(timeout=_PROCESS_DEADLINE) p2.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_write_lock_excludes_writers(lock_file: str) -> None: held, release = Event(), Event() second = Event() holder = Process(target=_worker, args=(lock_file, "write", held, release)) contender = Process(target=_worker, args=(lock_file, "write", second, None, 0.3, True)) with cleanup_processes([holder, contender]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) contender.start() assert not second.wait(timeout=0.5) release.set() holder.join(timeout=_PROCESS_DEADLINE) contender.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_write_lock_excludes_readers(lock_file: str) -> None: held, release = Event(), Event() reader_acquired = Event() writer = Process(target=_worker, args=(lock_file, "write", held, release)) reader = Process(target=_worker, args=(lock_file, "read", reader_acquired, None, 0.3, True)) with cleanup_processes([writer, reader]): writer.start() assert held.wait(timeout=_PROCESS_DEADLINE) reader.start() assert not reader_acquired.wait(timeout=0.5) release.set() writer.join(timeout=_PROCESS_DEADLINE) reader.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 5) def test_writer_drains_existing_readers(lock_file: str) -> None: r_held, r_release = Event(), Event() w_held = Event() reader = Process(target=_worker, args=(lock_file, "read", r_held, r_release)) writer = Process(target=_worker, args=(lock_file, "write", w_held)) with cleanup_processes([reader, writer]): reader.start() assert r_held.wait(timeout=_PROCESS_DEADLINE) writer.start() assert not w_held.wait(timeout=0.5) r_release.set() reader.join(timeout=_PROCESS_DEADLINE) assert w_held.wait(timeout=_PROCESS_DEADLINE) writer.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 7) def test_writer_preference_blocks_new_readers(lock_file: str) -> None: r1_held, r1_release = Event(), Event() w_held, w_release = Event(), Event() r2_held = Event() reader1 = Process(target=_worker, args=(lock_file, "read", r1_held, r1_release)) writer = Process(target=_worker, args=(lock_file, "write", w_held, w_release)) reader2 = Process(target=_worker, args=(lock_file, "read", r2_held, None, 10, True)) with cleanup_processes([reader1, writer, reader2]): reader1.start() assert r1_held.wait(timeout=_PROCESS_DEADLINE) writer.start() time.sleep(0.3) reader2.start() assert not r2_held.wait(timeout=0.5) r1_release.set() assert w_held.wait(timeout=_PROCESS_DEADLINE) assert not r2_held.wait(timeout=0.3) w_release.set() assert r2_held.wait(timeout=_PROCESS_DEADLINE) reader1.join(timeout=_PROCESS_DEADLINE) writer.join(timeout=_PROCESS_DEADLINE) reader2.join(timeout=_PROCESS_DEADLINE) @pytest.mark.timeout(_PROCESS_DEADLINE * 4) def test_transaction_lock_timeout_across_threads(lock_file: str) -> None: # Two threads share one lock instance. Thread A holds the transaction lock while spinning on a peer # writer; thread B times out on the transaction lock, exercising the in-process Timeout path rather # than cross-process contention. peer = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02, ) peer.acquire_write(timeout=2) try: lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02, ) try: thread_ready = threading.Event() release_thread = threading.Event() def target_a() -> None: thread_ready.set() with suppress(Timeout): lock.acquire_write(timeout=2) release_thread.wait(timeout=_PROCESS_DEADLINE) thread_a = threading.Thread(target=target_a) thread_a.start() try: thread_ready.wait(timeout=_PROCESS_DEADLINE) time.sleep(0.05) with pytest.raises(Timeout): lock.acquire_write(timeout=0.1) finally: release_thread.set() thread_a.join(timeout=_PROCESS_DEADLINE) finally: lock.close() finally: peer.release() peer.close() @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_two_readers_in_same_process_share_slot(lock_file: str) -> None: # Many threads take a read lock on one instance; one hits the inner reentrant branch (lock level # above 0 after waiting on the transaction lock). lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=0.1, stale_threshold=0.5, poll_interval=0.02, ) try: barrier = threading.Barrier(8) def target() -> None: barrier.wait(timeout=_PROCESS_DEADLINE) with lock.read_lock(timeout=5): time.sleep(0.05) threads = [threading.Thread(target=target) for _ in range(8)] for thread in threads: thread.start() for thread in threads: thread.join(timeout=_PROCESS_DEADLINE) finally: lock.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_timeout_raises(lock_file: str) -> None: held, release = Event(), Event() holder = Process(target=_worker, args=(lock_file, "write", held, release)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) lock = _make_lock(lock_file) try: with pytest.raises(Timeout): lock.acquire_write(timeout=0.3) finally: lock.close() release.set() holder.join(timeout=_PROCESS_DEADLINE) @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_non_blocking_writer_contended_raises(lock_file: str) -> None: held, release = Event(), Event() holder = Process(target=_worker, args=(lock_file, "write", held, release)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) lock = _make_lock(lock_file) try: with pytest.raises(Timeout): lock.acquire_write(timeout=1, blocking=False) with pytest.raises(Timeout): lock.acquire_read(timeout=1, blocking=False) finally: lock.close() release.set() holder.join(timeout=_PROCESS_DEADLINE) @pytest.mark.timeout(10) def test_writer_phase2_timeout_releases_marker(lock_file: str) -> None: # A live reader whose heartbeat stays fresh blocks the phase-2 drain; the writer must abandon its # phase-1 claim so the next writer can retry. reader = _make_lock(lock_file) reader.acquire_read(timeout=2) try: writer = _make_lock(lock_file) try: with pytest.raises(Timeout): writer.acquire_write(timeout=0.3) finally: writer.close() assert not Path(f"{lock_file}.write").exists() finally: reader.release() reader.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_POSIX_SIGNALS @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_dead_writer_evicted_by_reader(lock_file: str) -> None: # pragma: needs posix-signals held = Event() holder = Process(target=_sigkill_worker, args=(lock_file, "write", held, 0.1, 0.5)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) pid = holder.pid assert pid is not None os.kill(pid, getattr(signal, "SIGKILL")) # ruff:ignore[get-attr-with-constant] - signal.SIGKILL is POSIX-only holder.join(timeout=_PROCESS_DEADLINE) time.sleep(0.8) lock = _make_lock(lock_file) try: with lock.read_lock(timeout=5): pass finally: lock.close() assert not Path(f"{lock_file}.write").exists() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_POSIX_SIGNALS @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_dead_reader_evicted_by_writer(lock_file: str) -> None: # pragma: needs posix-signals held = Event() holder = Process(target=_sigkill_worker, args=(lock_file, "read", held, 0.1, 0.5)) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) pid = holder.pid assert pid is not None os.kill(pid, getattr(signal, "SIGKILL")) # ruff:ignore[get-attr-with-constant] - signal.SIGKILL is POSIX-only holder.join(timeout=_PROCESS_DEADLINE) time.sleep(0.8) lock = _make_lock(lock_file) try: with lock.write_lock(timeout=5): pass finally: lock.close() def test_heartbeat_self_stops_when_marker_vanishes(lock_file: str) -> None: lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) lock.acquire_write(timeout=2) try: Path(f"{lock_file}.write").unlink() time.sleep(0.15) # two-plus heartbeat ticks to observe the vanished marker and self-stop finally: lock.release(force=True) lock.close() # The vanished marker leaves nothing to evict, so a peer can acquire. peer = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) try: with peer.write_lock(timeout=1): pass finally: peer.close() def test_heartbeat_self_stops_on_token_replacement(lock_file: str) -> None: lock = _make_lock(lock_file, heartbeat_interval=0.05, stale_threshold=0.2) lock.acquire_write(timeout=2) try: # A well-formed marker holding a different token. Path(f"{lock_file}.write").write_bytes(b"0" * 32 + b"\n1\nhost\n") time.sleep(0.15) finally: lock.release(force=True) lock.close() def test_release_keeps_a_peers_writer_marker(lock_file: str) -> None: # A holder paused past the stale threshold (GC pause, SIGSTOP, suspended VM) can have its marker evicted # by a peer that then claims the writer slot. On release the holder must not unlink that peer's live # marker; unlinking it would let a second writer through and break mutual exclusion. lock = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) lock.acquire_write(timeout=2) try: write_marker = f"{lock_file}.write" peer_marker = b"a" * 32 + b"\n1\npeerhost\n" Path(write_marker).write_bytes(peer_marker) lock.release() assert Path(write_marker).read_bytes() == peer_marker finally: lock.close() def test_writer_phase2_does_not_complete_on_a_peers_marker(lock_file: str, monkeypatch: pytest.MonkeyPatch) -> None: # A writer paused past stale_threshold during phase 2 (waiting for readers to drain) can have its stale # marker evicted by a peer that reclaims .write with its own token. Phase 2 must notice the foreign marker # rather than keep touching it and completing the acquire as if we still held the slot, which would let two # writers run at once. A live reader keeps phase 2 looping; on the first poll sleep we simulate the eviction # by overwriting .write with a peer token and draining the reader. reader = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) reader.acquire_read(timeout=2) writer = _make_lock(lock_file, heartbeat_interval=10, stale_threshold=40) write_marker = f"{lock_file}.write" peer_marker = b"a" * 32 + b"\n1\npeerhost\n" real_sleep = time.sleep swapped = threading.Event() # Only the writer's phase-2 poll loop calls the patched sleep (the reader's heartbeat waits on an Event), so the # swap lands on the first poll and the second poll ends the wait. Driving the timeout from the hook instead of the # wall clock keeps this deterministic: a loaded runner could otherwise blow the deadline during phase-2 setup and # raise Timeout before the first sleep ever ran, leaving the swap uninjected. def hook(seconds: float) -> None: # ruff:ignore[unused-function-argument] # replaces time.sleep; the duration is irrelevant to the swap if swapped.is_set(): raise Timeout(lock_file) swapped.set() Path(write_marker).write_bytes(peer_marker) reader.release() monkeypatch.setattr(sync_mod.time, "sleep", hook) try: with pytest.raises(Timeout): writer.acquire_write(timeout=30) assert swapped.is_set() # We never overwrote or refreshed the peer's live marker. assert Path(write_marker).read_bytes() == peer_marker finally: monkeypatch.setattr(sync_mod.time, "sleep", real_sleep) writer.close() reader.close() def test_heartbeat_survives_transient_touch_error(lock_file: str, monkeypatch: pytest.MonkeyPatch) -> None: # On the NFS-style filesystems this lock targets, a transient ESTALE/EIO on the heartbeat touch is # routine; it must not kill the heartbeat and drop the lease while we still believe we hold it. def boom(name: str, *, fd: int | None = None) -> None: # ruff:ignore[unused-function-argument] # matches the patched touch signature and always raises raise OSError(EIO, "Input/output error") lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.2) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None monkeypatch.setattr(sync_mod, "touch", boom) time.sleep(0.2) # ~10 ticks, every one failing the touch assert hold.heartbeat_thread.is_alive() assert not hold.heartbeat_stop.is_set() finally: lock.release(force=True) lock.close() @pytest.mark.parametrize( "target", [pytest.param("_open_marker_fd", id="open"), pytest.param("_read_marker_fd", id="read")] ) def test_heartbeat_survives_a_transient_marker_error( lock_file: str, monkeypatch: pytest.MonkeyPatch, target: str ) -> None: # A transient ESTALE/EIO opening or reading the marker is routine on the NFS-style filesystems this lock targets. # Unlike the marker actually vanishing, it must not stop the heartbeat and drop the lease. def boom(*_args: object, **_kwargs: object) -> None: raise OSError(EIO, "Input/output error") lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.2) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None monkeypatch.setattr(sync_mod, target, boom) time.sleep(0.2) # ~10 ticks, every one failing the open or read assert hold.heartbeat_thread.is_alive() assert not hold.heartbeat_stop.is_set() finally: lock.release(force=True) lock.close() def test_heartbeat_stops_when_marker_evicted(lock_file: str, monkeypatch: pytest.MonkeyPatch) -> None: # An O_NOFOLLOW open failing with ENOENT means the marker we held is gone: a peer evicted us. Unlike a transient # filesystem error, this is an unambiguous loss, so the heartbeat must stop rather than keep retrying. def gone(name: str, *, dir_fd: int | None = None) -> int: # ruff:ignore[unused-function-argument] # matches the patched _open_marker_fd signature and always raises raise FileNotFoundError(ENOENT, "No such file or directory") lock = _make_lock(lock_file, heartbeat_interval=0.02, stale_threshold=0.2) lock.acquire_write(timeout=2) try: hold = lock._hold assert hold is not None monkeypatch.setattr(sync_mod, "_open_marker_fd", gone) assert hold.heartbeat_stop.wait(timeout=_PROCESS_DEADLINE) hold.heartbeat_thread.join(timeout=_PROCESS_DEADLINE) assert not hold.heartbeat_thread.is_alive() finally: lock.release(force=True) lock.close() @pytest.mark.parametrize("mode", [pytest.param("write", id="write"), pytest.param("read", id="read")]) def test_acquire_hands_back_the_slot_when_the_heartbeat_cannot_start( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: # A heartbeat thread the OS refuses (an rlimit reached) must not leave a hold behind: a peer would evict the # unrefreshed marker and acquire while this instance still believed it held the lock, and release() would raise # joining a thread that never started. mocker.patch.object(sync_mod._HeartbeatThread, "start", side_effect=RuntimeError("can't start new thread")) lock = _make_lock(lock_file) acquire = lock.acquire_write if mode == "write" else lock.acquire_read with pytest.raises(RuntimeError, match="can't start new thread"): acquire(timeout=2) assert lock._hold is None assert not Path(lock._paths.write).exists() assert not lock._any_readers() lock.release(force=True) # a handed-back slot leaves nothing to release, so this must not raise lock.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.timeout(_PROCESS_DEADLINE * 3) def test_live_heartbeat_keeps_lock_alive_past_stale_threshold(lock_file: str) -> None: # Generous timing here so the test stays stable on slow Windows runners where the holder's # multiprocessing.spawn startup, the heartbeat thread scheduling, and the parent's mtime resolution # can all introduce sub-second jitter. heartbeat, stale = 0.3, 1.5 held, release = Event(), Event() holder = Process( target=_worker, args=(lock_file, "write", held, release, -1, True, heartbeat, stale, 0.05), ) with cleanup_processes([holder]): holder.start() assert held.wait(timeout=_PROCESS_DEADLINE) time.sleep(stale * 2) lock = _make_lock(lock_file, heartbeat_interval=heartbeat, stale_threshold=stale) try: with pytest.raises(Timeout): lock.acquire_write(timeout=0.5) finally: lock.close() release.set() holder.join(timeout=_PROCESS_DEADLINE) @pytest.mark.parametrize( "content", [ pytest.param(b"deadbeefdeadbeefdeadbeefdeadbeef\nnotanumber\nhost\n", id="non-numeric-pid"), pytest.param(b"deadbeefdeadbeefdeadbeefdeadbeef\n0\nhost\n", id="zero-pid"), pytest.param(b"deadbeefdeadbeefdeadbeefdeadbeef\n9999999999\nhost\n", id="pid-too-large"), pytest.param(b"bogus\n4711\nhost\n", id="wrong-length-token"), pytest.param(b"ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ\n4711\nhost\n", id="non-hex-token"), pytest.param(b"deadbeefdeadbeefdeadbeefdeadbeef\n4711\nhost with space\n", id="hostname-space"), pytest.param(b"deadbeefdeadbeefdeadbeefdeadbeef\n4711\nhost\n\n\n", id="trailing-blank-lines"), pytest.param(b"only one line\n", id="too-few-lines"), pytest.param(b"a\nb\nc\nd\n", id="too-many-lines"), pytest.param(b"x" * 2048, id="oversized"), pytest.param("ééé\n4711\nhost\n".encode(), id="non-ascii"), ], ) def test_stale_malformed_marker_is_evicted(lock_file: str, content: bytes) -> None: _write_stale_marker(f"{lock_file}.write", content) lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2): pass finally: lock.close() def test_fifo_write_marker_does_not_block(lock_file: str) -> None: # pragma: needs fifo if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below marker = f"{lock_file}.write" os.mkfifo(marker) past = time.time() - 1000 os.utime(marker, (past, past)) # Without O_NONBLOCK this open blocks forever; the lock instead reads the FIFO as a stale marker and evicts it. lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2): pass finally: lock.close() def test_fifo_write_marker_with_writer_is_evicted(lock_file: str) -> None: # pragma: needs fifo if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below marker = f"{lock_file}.write" os.mkfifo(marker) past = time.time() - 1000 os.utime(marker, (past, past)) # A writer attached to the FIFO makes the non-blocking read raise EAGAIN on all platforms, matching a # writerless FIFO on FreeBSD (#587). The lock must evict the stale marker by mtime without reading it, so # the acquire completes instead of timing out. reader_fd = os.open(marker, os.O_RDONLY | os.O_NONBLOCK) writer_fd = os.open(marker, os.O_WRONLY) lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2): pass finally: lock.close() os.close(writer_fd) os.close(reader_fd) @pytest.mark.skipif(not hasattr(os, "O_NOFOLLOW"), reason="O_NOFOLLOW required") def test_symlinked_write_marker_is_refused(lock_file: str, tmp_path: Path) -> None: # pragma: needs o-nofollow victim = tmp_path / "victim" victim.write_text("do-not-touch") Path(f"{lock_file}.write").symlink_to(victim) lock = _make_lock(lock_file) try: with pytest.raises((OSError, Timeout)): lock.acquire_write(timeout=0.5) finally: lock.close() assert victim.read_text() == "do-not-touch" @pytest.mark.skipif( os.utime not in os.supports_follow_symlinks, reason="os.utime cannot refuse symlinks on this platform" ) def test_touch_does_not_follow_symlink(lock_file: str, tmp_path: Path) -> None: # pragma: needs utime-nofollow # The phase-2 writer-drain touch refreshes the .write marker by path (no held fd); if a peer swaps a # symlink in, the touch must land on the link itself, not the file it points at. victim = tmp_path / "victim" victim.write_text("do-not-touch") past = time.time() - 1000 os.utime(victim, (past, past)) marker = Path(f"{lock_file}.write") marker.symlink_to(victim) util_mod.touch(str(marker)) assert victim.stat().st_mtime == pytest.approx(past) # a timestamp round-trip need not be bit-exact assert victim.read_text() == "do-not-touch" @pytest.mark.skipif(not util_mod._SUPPORTS_UTIME_FD, reason="os.utime cannot target an fd on this platform") def test_refresh_touches_verified_fd_not_swapped_path( # pragma: needs utime-fd lock_file: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # A peer can swap our marker for a symlink in the window after the heartbeat's O_NOFOLLOW open but before # the touch; because the refresh touches the verified fd, the swapped symlink's target stays untouched. victim = tmp_path / "victim" victim.write_text("do-not-touch") past = time.time() - 1000 os.utime(victim, (past, past)) lock = _make_lock(lock_file, heartbeat_interval=30, stale_threshold=90) lock.acquire_write(timeout=2) try: marker = Path(f"{lock_file}.write") real_open = sync_mod._open_marker def swap_after_open(name: str, *, dir_fd: int | None = None) -> int | None: fd = real_open(name, dir_fd=dir_fd) if fd is not None and Path(name) == marker: # pragma: no branch # the refresh only opens the marker marker.unlink() marker.symlink_to(victim) return fd monkeypatch.setattr(sync_mod, "_open_marker", swap_after_open) assert lock._refresh_marker() is True assert victim.stat().st_mtime == pytest.approx(past) # a timestamp round-trip need not be bit-exact assert victim.read_text() == "do-not-touch" finally: lock.release(force=True) lock.close() @pytest.mark.skipif(not CAPABILITIES["symlink"], reason="staging the readers directory as a symlink") def test_symlinked_readers_directory_is_refused(lock_file: str, tmp_path: Path) -> None: # pragma: needs symlink victim_dir = tmp_path / "victim_dir" victim_dir.mkdir() Path(f"{lock_file}.readers").symlink_to(victim_dir) lock = _make_lock(lock_file) try: with pytest.raises(RuntimeError, match="not a directory or is a symlink"): lock.acquire_read(timeout=0.5) finally: lock.close() assert list(victim_dir.iterdir()) == [] def test_readers_path_as_regular_file_is_refused(lock_file: str) -> None: Path(f"{lock_file}.readers").write_bytes(b"x") lock = _make_lock(lock_file) try: with pytest.raises(RuntimeError, match="not a directory or is a symlink"): lock.acquire_read(timeout=0.5) finally: lock.close() @NEEDS_FILE_MODE def test_write_marker_is_created_with_0600(lock_file: str) -> None: # pragma: needs file-mode lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2): assert stat.S_IMODE(Path(f"{lock_file}.write").lstat().st_mode) == 0o600 finally: lock.close() @NEEDS_FILE_MODE def test_readers_directory_is_created_with_0700(lock_file: str) -> None: # pragma: needs file-mode lock = _make_lock(lock_file) try: with lock.read_lock(timeout=2): assert stat.S_IMODE(Path(f"{lock_file}.readers").lstat().st_mode) == 0o700 finally: lock.close() def test_writer_ignores_housekeeping_files_in_readers_dir(lock_file: str) -> None: # A writer's phase-2 drain scan must not mistake dotfiles or leftover .break.* files from aborted # evictions for live readers. readers = Path(f"{lock_file}.readers") readers.mkdir(mode=0o700, exist_ok=True) (readers / ".hidden").write_bytes(b"ignored") (readers / "stale.break.12345.abcdef").write_bytes(b"also ignored") lock = _make_lock(lock_file) try: with lock.write_lock(timeout=2): pass finally: lock.close() @NEEDS_FILE_MODE def test_reader_file_is_created_with_0600(lock_file: str) -> None: # pragma: needs file-mode lock = _make_lock(lock_file) try: with lock.read_lock(timeout=2): entries = list(Path(f"{lock_file}.readers").iterdir()) assert len(entries) == 1 assert stat.S_IMODE(entries[0].lstat().st_mode) == 0o600 finally: lock.close() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) def test_child_cannot_reuse_parents_lock_instance(tmp_path: Path) -> None: # pragma: needs fork ctx = mp.get_context("spawn") result, failure = ctx.Event(), ctx.Event() proc = ctx.Process(target=_reuse_inherited_lock, args=(str(tmp_path / "foo.lock"), result, failure)) with cleanup_processes([proc]): proc.start() proc.join(timeout=_PROCESS_DEADLINE) assert not failure.is_set() assert result.is_set() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) def test_child_release_on_inherited_lock_is_silent(tmp_path: Path) -> None: # pragma: needs fork ctx = mp.get_context("spawn") result, failure = ctx.Event(), ctx.Event() proc = ctx.Process(target=_release_inherited_lock, args=(str(tmp_path / "foo.lock"), result, failure)) with cleanup_processes([proc]): proc.start() proc.join(timeout=_PROCESS_DEADLINE) assert not failure.is_set() assert result.is_set() @SKIP_ON_UNRELIABLE_PROCESS_SYNC @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) def test_child_can_acquire_a_different_lock_after_fork(tmp_path: Path) -> None: # pragma: needs fork ctx = mp.get_context("spawn") result, failure = ctx.Event(), ctx.Event() proc = ctx.Process( target=_reacquire_fresh_lock_in_child, args=(str(tmp_path / "parent.lock"), str(tmp_path / "child.lock"), result, failure), ) with cleanup_processes([proc]): proc.start() proc.join(timeout=_PROCESS_DEADLINE) assert not failure.is_set() assert result.is_set() @NEEDS_FORK @pytest.mark.timeout(_PROCESS_DEADLINE * 2) # Holding the write lock keeps the heartbeat thread alive, so this fork is necessarily from a multi-threaded # process and Python 3.15 warns that it may deadlock. That is the scenario under test, and it is already safe: # register_at_fork resets inherited state in the child (any child use raises "invalidated by fork()"). Expected. @SKIP_ON_UNRELIABLE_PROCESS_SYNC @pytest.mark.filterwarnings("ignore:.*multi-threaded, use of fork.*:DeprecationWarning") def test_parent_retains_lock_across_fork(tmp_path: Path) -> None: # pragma: needs fork path = str(tmp_path / "foo.lock") lock = SoftReadWriteLock(path, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_write(timeout=5) try: child = _fork_process(target=time.sleep, args=(0.05,)) child.start() child.join(timeout=_PROCESS_DEADLINE) assert Path(f"{path}.write").exists() peer = SoftReadWriteLock( path, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02, is_singleton=False, ) try: with pytest.raises(Timeout): peer.acquire_write(timeout=0.3) finally: peer.close() finally: lock.release() lock.close() assert not Path(f"{path}.write").exists() def _make_lock( path: str, *, heartbeat_interval: float = 0.1, stale_threshold: float = 0.5, poll_interval: float = 0.02, is_singleton: bool = False, ) -> SoftReadWriteLock: return SoftReadWriteLock( path, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, is_singleton=is_singleton, ) def _worker( lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, release_event: EventType | None = None, timeout: float = -1, blocking: bool = True, heartbeat_interval: float = 0.1, stale_threshold: float = 1.0, poll_interval: float = 0.02, ) -> None: lock = SoftReadWriteLock( lock_file, timeout=timeout, blocking=blocking, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=poll_interval, ) try: with lock.read_lock() if mode == "read" else lock.write_lock(): acquired_event.set() if release_event is not None: release_event.wait(timeout=_PROCESS_DEADLINE) else: time.sleep(0.2) finally: lock.close() def _sigkill_worker( # pragma: forked child lock_file: str, mode: Literal["read", "write"], acquired_event: EventType, heartbeat_interval: float, stale_threshold: float, ) -> None: lock = SoftReadWriteLock( lock_file, is_singleton=False, heartbeat_interval=heartbeat_interval, stale_threshold=stale_threshold, poll_interval=0.05, ) if mode == "read": lock.acquire_read() else: lock.acquire_write() acquired_event.set() time.sleep(60) def _write_stale_marker(path: str, content: bytes) -> None: Path(path).write_bytes(content) past = time.time() - 1000 os.utime(path, (past, past)) def _reuse_inherited_lock(lock_file: str, result: EventType, failure: EventType) -> None: # pragma: needs fork lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_write(timeout=5) ok = _fork_event() def child_entry() -> None: try: lock.acquire_read(timeout=1) except RuntimeError as exc: if "invalidated by fork" in str(exc): # pragma: no branch # the inherited lock raises nothing else ok.set() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child fails to report the inherited lock, i.e. a regression failure.set() lock.release() lock.close() def _release_inherited_lock(lock_file: str, result: EventType, failure: EventType) -> None: # pragma: needs fork lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) lock.acquire_read(timeout=5) ok = _fork_event() def child_entry() -> None: try: lock.release() except RuntimeError: # pragma: no cover # release on a fork-inherited lock is silent; guards a regression return ok.set() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child's silent release regresses into raising failure.set() lock.release() lock.close() def _reacquire_fresh_lock_in_child( # pragma: needs fork lock_file: str, child_path: str, result: EventType, failure: EventType ) -> None: parent_lock = SoftReadWriteLock(lock_file, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02) parent_lock.acquire_write(timeout=5) ok = _fork_event() def child_entry() -> None: child_lock = SoftReadWriteLock( child_path, is_singleton=False, heartbeat_interval=0.2, stale_threshold=1.0, poll_interval=0.02, ) try: with child_lock.read_lock(timeout=2): ok.set() finally: child_lock.close() child = _fork_process(target=child_entry) child.start() child.join(timeout=_PROCESS_DEADLINE) if ok.is_set(): result.set() else: # pragma: no cover # reached only if the child cannot acquire a fresh lock after the fork, i.e. a regression failure.set() parent_lock.release() parent_lock.close() def _fork_process( # pragma: needs fork target: Callable[..., object], args: tuple[object, ...] = () ) -> mp.process.BaseProcess: if sys.platform == "win32": # pragma: win32 cover msg = "fork context is POSIX only" raise RuntimeError(msg) return mp.get_context("fork").Process(target=target, args=args) def _fork_event() -> EventType: # pragma: needs fork if sys.platform == "win32": # pragma: win32 cover msg = "fork context is POSIX only" raise RuntimeError(msg) return mp.get_context("fork").Event() @SKIP_ON_UNRELIABLE_PROCESS_SYNC def test_cleanup_terminates_a_still_running_process() -> None: proc = Process(target=time.sleep, args=(30,)) proc.start() started = time.monotonic() with cleanup_processes([proc]): assert proc.is_alive() # A helper that only joined would sit here for the child's whole sleep instead of signaling it first. assert time.monotonic() - started < 10 @SKIP_ON_UNRELIABLE_PROCESS_SYNC def test_cleanup_closes_the_process() -> None: proc = Process(target=time.sleep, args=(30,)) proc.start() with cleanup_processes([proc]): assert proc.is_alive() with pytest.raises(ValueError, match="process object is closed"): proc.is_alive() def test_write_marker_zero_write_rolls_back(lock_file: str, mocker: MockerFixture) -> None: mocker.patch("filelock._util.os.write", return_value=0) lock = SoftReadWriteLock(lock_file, is_singleton=False) with pytest.raises(OSError, match="0 bytes"): lock.acquire_write(timeout=1) assert not Path(f"{lock_file}.write").exists() def test_touch_writer_marker_returns_false_when_marker_missing(lock_file: str) -> None: lock = SoftReadWriteLock(lock_file, is_singleton=False) assert lock._touch_writer_marker_if_ours("0" * 32) is False def test_claim_writer_marker_returns_false_on_create_race(lock_file: str, mocker: MockerFixture) -> None: lock = SoftReadWriteLock(lock_file, is_singleton=False) mocker.patch.object(sync_mod, "_atomic_create_marker", side_effect=FileExistsError) with lock._locks.state: assert lock._claim_writer_marker("0" * 32) is False def test_atomic_create_marker_rolls_back_on_write_failure(tmp_path: Path, mocker: MockerFixture) -> None: marker = str(tmp_path / "marker") mocker.patch.object(sync_mod, "write_all", side_effect=OSError("write boom")) with pytest.raises(OSError, match="write boom"): sync_mod._atomic_create_marker(marker, "0" * 32) assert not Path(marker).exists() def test_same_file_true_for_matching_identity(tmp_path: Path) -> None: target = tmp_path / "present" target.write_bytes(b"x") st = os.lstat(target) assert sync_mod._same_file(str(target), (st.st_dev, st.st_ino), dir_fd=None) is True def test_same_file_false_when_stat_fails(tmp_path: Path) -> None: assert sync_mod._same_file(str(tmp_path / "absent"), (1, 2), dir_fd=None) is False def test_refresh_marker_stops_once_a_peer_owns_the_marker(lock_file: str) -> None: # A peer that evicted our marker and wrote its own owns the file now; refreshing it would keep a stranger's # marker alive, so the heartbeat has to stop instead. lock = _make_lock(lock_file) try: lock.acquire_write(timeout=2) foreign = f"{'0' * 32}\n{os.getpid()}\n{socket.gethostname()}\n".encode("ascii") Path(f"{lock_file}.write").write_bytes(foreign) assert lock._refresh_marker() is False finally: lock.close() filelock-3.32.2/.gitignore0000644000000000000000000000030513615410400012277 0ustar00*.egg-info build dist *.egg .eggs *.py[codz] *$py.class .tox .*_cache .DS_Store .idea .vscode /pip-wheel-metadata /src/filelock/version.py venv* .python-version test.lock .coverage.* test.softlock filelock-3.32.2/LICENSE0000644000000000000000000000210013615410400011307 0ustar00MIT License Copyright (c) 2025 Bernát Gábor and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. filelock-3.32.2/README.md0000644000000000000000000000135313615410400011572 0ustar00# filelock [![PyPI](https://img.shields.io/pypi/v/filelock)](https://pypi.org/project/filelock/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/filelock.svg)](https://pypi.org/project/filelock/) [![Documentation status](https://readthedocs.org/projects/py-filelock/badge/?version=latest)](https://py-filelock.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://static.pepy.tech/badge/filelock/month)](https://pepy.tech/project/filelock) [![check](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml) For more information checkout the [official documentation](https://py-filelock.readthedocs.io/en/latest/index.html). filelock-3.32.2/pyproject.toml0000644000000000000000000002060013615410400013223 0ustar00[build-system] build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.5", "hatchling>=1.29", ] [project] name = "filelock" description = "A platform independent file lock." readme = "README.md" keywords = [ "application", "cache", "directory", "log", "user", ] license = "MIT" maintainers = [ { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Topic :: Internet", "Topic :: Software Development :: Libraries", "Topic :: System", ] dynamic = [ "version", ] urls.Documentation = "https://py-filelock.readthedocs.io" urls.Homepage = "https://github.com/tox-dev/py-filelock" urls.Source = "https://github.com/tox-dev/py-filelock" urls.Tracker = "https://github.com/tox-dev/py-filelock/issues" [dependency-groups] dev = [ { include-group = "coverage" }, { include-group = "docs" }, { include-group = "fix" }, { include-group = "pkg-meta" }, { include-group = "release" }, { include-group = "test" }, { include-group = "type" }, ] test = [ "covdefaults>=2.3", "diff-cover>=10.2", "exceptiongroup>=1.2; python_version<'3.11'", "pytest>=9.0.3", "pytest-asyncio>=1.3", "pytest-cov>=7.1", "pytest-mock>=3.15.1", "pytest-randomly>=3.16", "pytest-timeout>=2.4", "virtualenv>=21.2", ] type = [ "ty>=0.0.30", { include-group = "docs" }, { include-group = "release" }, { include-group = "test" }, ] docs = [ "furo>=2025.12.19", "sphinx>=9.1", "sphinx-autodoc-typehints>=3.9.11", "sphinx-copybutton>=0.5.2", "sphinx-design>=0.7", "sphinx-sitemap>=2.9", "sphinxcontrib-mermaid>=2.0.1", "sphinxcontrib-towncrier>=0.5.0a0", "sphinxext-opengraph>=0.13", "towncrier>=24.8", ] coverage = [ "covdefaults>=2.3", "coverage[toml]>=7.13.5", "diff-cover>=10.2", ] fix = [ "pre-commit-uv>=4.2.1", ] pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.11.5", ] release = [ "towncrier>=24.8", ] [tool.hatch] version.source = "vcs" build.hooks.vcs.version-file = "src/filelock/version.py" build.targets.sdist.include = [ "/src", "/tasks", "/tests", "/tox.toml", ] [tool.ruff] line-length = 120 format.preview = true format.docstring-code-line-length = 100 format.docstring-code-format = true lint.select = [ "ALL", ] lint.ignore = [ "CPY", # No copyright statements "DOC", # no support yet "escape-sequence-in-docstring", # Use `r"""` if any backslashes in a docstring "hardcoded-bind-all-interfaces", # Possible binding to all interface "incorrect-blank-line-before-class", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible "missing-blank-line-after-summary", # 1 blank line required between summary line and description "missing-trailing-comma", # Conflict with formatter "multi-line-summary-first-line", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "non-empty-init-module", # `__init__` module should only contain docstrings and re-exports "non-imperative-mood", # First line of docstring should be in imperative mood "single-line-implicit-string-concatenation", # Conflict with formatter ] lint.per-file-ignores."tasks/**/*.py" = [ "assert", # asserts state the platform a Windows-only script already refuses to run outside of, narrowing it for ty "D", # don't care about documentation in tasks scripts "implicit-namespace-package", # no implicit namespace "print", # print allowed in scripts "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input ] lint.per-file-ignores."tests/**/*.py" = [ "assert", # asserts allowed in tests "blind-except", # blind exception catching acceptable in test helpers "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests "implicit-namespace-package", # no implicit namespace "import-outside-top-level", # import should be at top-level "import-private-name", # private name import from external module "magic-value-comparison", # Magic value used in comparison, consider replacing with a constant variable "non-lowercase-variable-in-function", # variable in function should be lowercase "private-member-access", # accessing private members acceptable in tests "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input "too-many-arguments", # too many arguments in function definition "too-many-positional-arguments", # too many positional arguments "too-many-statements-in-try-clause", # try/finally cleanup blocks in tests legitimately hold many statements ] lint.isort = { known-first-party = [ "filelock", ], required-imports = [ "from __future__ import annotations", ] } lint.preview = true [tool.codespell] builtin = "clear,usage,en-GB_to_en-US" ignore-words-list = "master" count = true quiet-level = 3 [tool.pyproject-fmt] max_supported_python = "3.15" [tool.ty] src.exclude = [] # Mirrors pytest's pythonpath so the tests' `coverage_pragmas` import resolves here too. environment.extra-paths = [ ".", "tasks", ] environment.python-version = "3.14" [tool.pytest] ini_options.testpaths = [ "tests", ] # The project root lets the tests import each other as tests.*; tasks holds coverage_pragmas, whose CAPABILITIES probes # drive both the coverage exclusions and the skipif gates, so a test can never be skipped while coverage still demands # its lines. coverage imports that plugin by name in the subprocesses it patches, so it stays a top-level module. ini_options.pythonpath = [ ".", "tasks", ] ini_options.markers = [ "requires_hard_links: exercises StrictSoftFileLock, which needs os.link; skipped where it is missing (Termux/Android)", ] ini_options.filterwarnings = [ "error", # CPython's resource tracker refuses to unregister a semaphore while it is already cleaning up, and warns instead. # multiprocessing.Event and Value expose no close, so only the collector frees them, and it picks the moment. Seen # on 3.12 alone. Filtering the tracker's own warning keeps the finalizer from raising rather than hiding the raise. "ignore:ResourceTracker called reentrantly for resource cleanup:UserWarning", ] ini_options.verbosity_assertions = 2 ini_options.asyncio_default_fixture_loop_scope = "session" ini_options.timeout = 20 [tool.coverage] run.concurrency = [ "multiprocessing", "thread", ] # The pragma plugin configures the run; measuring it would report the tooling alongside the package under test. run.omit = [ "*/tasks/*", ] run.parallel = true run.patch = [ "_exit", "subprocess", ] run.plugins = [ "covdefaults", "coverage_pragmas", ] run.sigterm = true paths.other = [ ".", "*/filelock", "*\\filelock", ] paths.source = [ "src", "*/.tox/*/lib/*/site-packages", "*/.tox/*/Lib/site-packages", "**/src", "**\\src", ] report.fail_under = 100 html.skip_covered = false [tool.towncrier] name = "filelock" directory = "docs/changelog" filename = "docs/changelog.rst" template = "docs/changelog/template.jinja2" title_format = false issue_format = ":pr:`{issue}`" type = [ { directory = "breaking", name = "Backward incompatible changes", showcontent = true }, { directory = "feature", name = "Features", showcontent = true }, { directory = "bugfix", name = "Bug fixes", showcontent = true }, { directory = "doc", name = "Improved documentation", showcontent = true }, { directory = "packaging", name = "Packaging updates", showcontent = true }, ] filelock-3.32.2/PKG-INFO0000644000000000000000000000375413615410400011417 0ustar00Metadata-Version: 2.4 Name: filelock Version: 3.32.2 Summary: A platform independent file lock. Project-URL: Documentation, https://py-filelock.readthedocs.io Project-URL: Homepage, https://github.com/tox-dev/py-filelock Project-URL: Source, https://github.com/tox-dev/py-filelock Project-URL: Tracker, https://github.com/tox-dev/py-filelock/issues Maintainer-email: Bernát Gábor License-Expression: MIT License-File: LICENSE Keywords: application,cache,directory,log,user Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Topic :: Internet Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: System Requires-Python: >=3.10 Description-Content-Type: text/markdown # filelock [![PyPI](https://img.shields.io/pypi/v/filelock)](https://pypi.org/project/filelock/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/filelock.svg)](https://pypi.org/project/filelock/) [![Documentation status](https://readthedocs.org/projects/py-filelock/badge/?version=latest)](https://py-filelock.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://static.pepy.tech/badge/filelock/month)](https://pepy.tech/project/filelock) [![check](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/py-filelock/actions/workflows/check.yaml) For more information checkout the [official documentation](https://py-filelock.readthedocs.io/en/latest/index.html).