From 63a038af3e2561dc0f0dbced0ac37dffe9f8dd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 13 Jul 2026 22:41:21 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(async):=20make=20cancell?= =?UTF-8?q?ation=20atomic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep executor-backed operations alive through caller cancellation and serialize lock state transitions so rollback cannot release a later acquisition. Preserve all cancellation, callback, and backend failures without corrupting file or SQLite lock state. Fixes #640 --- docs/changelog/640.bugfix.rst | 4 + src/filelock/_api.py | 220 +++++++- src/filelock/_async.py | 158 ++++++ src/filelock/_async_read_write.py | 108 +++- src/filelock/_read_write.py | 83 +-- src/filelock/asyncio.py | 234 ++++++-- tests/async_filelock_cancellation_helpers.py | 69 +++ tests/read_write_helpers.py | 38 ++ tests/test_async_filelock.py | 59 +- ...est_async_filelock_acquire_cancellation.py | 478 ++++++++++++++++ ...est_async_filelock_release_cancellation.py | 235 ++++++++ tests/test_async_filelock_runner_shutdown.py | 195 +++++++ ...est_async_filelock_transition_admission.py | 123 +++++ tests/test_async_read_write.py | 24 +- tests/test_async_read_write_cancellation.py | 508 ++++++++++++++++++ tests/test_filelock.py | 69 +++ tests/test_read_write.py | 87 ++- 17 files changed, 2546 insertions(+), 146 deletions(-) create mode 100644 docs/changelog/640.bugfix.rst create mode 100644 src/filelock/_async.py create mode 100644 tests/async_filelock_cancellation_helpers.py create mode 100644 tests/read_write_helpers.py create mode 100644 tests/test_async_filelock_acquire_cancellation.py create mode 100644 tests/test_async_filelock_release_cancellation.py create mode 100644 tests/test_async_filelock_runner_shutdown.py create mode 100644 tests/test_async_filelock_transition_admission.py create mode 100644 tests/test_async_read_write_cancellation.py diff --git a/docs/changelog/640.bugfix.rst b/docs/changelog/640.bugfix.rst new file mode 100644 index 00000000..75dc1e9b --- /dev/null +++ b/docs/changelog/640.bugfix.rst @@ -0,0 +1,4 @@ +Keep executor-backed lock operations alive until they finish when a caller cancels. Serialize acquisitions on the same +async lock so cancellation rollback cannot release a later caller's hold. Roll back acquisitions that finish after +cancellation and surface release failures. SQLite read-write locks keep their state until rollback ends the transaction; +callers can retry cleanup with ``release(force=True)`` or another acquisition. diff --git a/src/filelock/_api.py b/src/filelock/_api.py index 63bce0a2..a9e24c0e 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -9,9 +9,10 @@ import time import warnings from abc import ABCMeta, abstractmethod +from collections.abc import Callable from dataclasses import dataclass from threading import Lock, local -from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, TypeVar, cast +from typing import TYPE_CHECKING, Final, Literal, NoReturn, TypeVar, cast from weakref import WeakKeyDictionary, WeakValueDictionary from ._error import Timeout @@ -30,8 +31,8 @@ _CLOSE_ERROR_POLICIES: Final[frozenset[str]] = frozenset({"default", "raise", "suppress"}) if TYPE_CHECKING: - from collections.abc import Callable from types import TracebackType + from typing import Protocol from ._read_write import ReadWriteLock from ._soft_rw import SoftReadWriteLock @@ -41,9 +42,13 @@ else: # pragma: no cover ( type[BaseException]: # BaseExceptionGroup is a builtin on 3.11+; on 3.10 it needs the exceptiongroup backport. filelock keeps zero @@ -56,10 +61,178 @@ def _exception_group_cls() -> type[BaseException]: return _Backport # pragma: no cover ( NoReturn: - if second_error.__context__ is first_error: - second_error.__context__ = None - raise _exception_group_cls()(message, (first_error, second_error)) from None +def _raise_grouped_errors( + message: str, + first_error: BaseException, + second_error: BaseException, + *additional_errors: BaseException, + marker: tuple[str, _MarkerValue] | None = None, +) -> 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 # noqa: TRY301 # 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 # noqa: TRY301 # the second raise needs this error as implicit context + except BaseException: # noqa: BLE001 # first_error may be a control-flow exception + first_error.__context__ = first_context + try: + raise second_error # noqa: TRY301 # 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: @@ -122,7 +295,7 @@ def __call__( # noqa: PLR0913 fallback_to_soft: bool = True, preserve_lock_file: bool = False, on_acquired: Callable[[int], None] | None = None, - **kwargs: Any, # capture remaining kwargs for subclasses # noqa: ANN401 + **kwargs: _ExtraValue, ) -> _T: lifetime = _resolve_lifetime(lifetime, supported=cls._lifetime_supported, cls_name=cls.__name__) # Validate before building the instance: a raise inside __init__ would leave a half-constructed object whose @@ -133,7 +306,7 @@ def __call__( # noqa: PLR0913 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 = { + params: dict[str, _LockInitValue | _ExtraValue] = { "timeout": timeout, "mode": mode, "thread_local": thread_local, @@ -196,7 +369,9 @@ def __call__( # noqa: PLR0913 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, Any]) -> _T: + 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) @@ -354,7 +529,7 @@ class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): # noqa #: protocol state in the marker and reject it. _on_acquired_supported: bool = True - def __init_subclass__(cls, **kwargs: dict[str, Any]) -> None: + def __init_subclass__(cls, **kwargs: _SubclassValue) -> None: """Give each lock subclass its own singleton registry and lock.""" super().__init_subclass__(**kwargs) cls._instances = WeakValueDictionary() @@ -438,16 +613,14 @@ def __init__( # noqa: PLR0913 self._preserve_lock_file = preserve_lock_file # already validated by the metaclass self._on_acquired = on_acquired # already validated by the metaclass - # External code reaches these values through the public properties, not through _context directly. - kwargs: dict[str, Any] = { - "lock_file": os.fspath(lock_file), - "timeout": timeout, - "mode": mode, - "blocking": blocking, - "poll_interval": poll_interval, - "lifetime": lifetime, - } - self._context: FileLockContext = (ThreadLocalFileContext if thread_local else FileLockContext)(**kwargs) + 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, + ) def is_thread_local(self) -> bool: """:returns: a flag indicating if this lock is thread local or not""" @@ -859,6 +1032,7 @@ def _invoke_on_acquired(self) -> None: 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__ # Undo the native acquisition so the failed hook does not leave the lock held. Call the backend _release # directly rather than the public release(): the counter and thread-local deadlock registry are reconciled # by acquire() on the owning thread, and the async release() is a coroutine this synchronous path cannot @@ -867,6 +1041,7 @@ def _invoke_on_acquired(self) -> None: self._release() except BaseException as release_error: # noqa: BLE001 # both errors surface via the group below _raise_body_and_release(callback_error, release_error) + callback_error.__context__ = callback_context raise def _undo_acquire(self) -> None: @@ -999,7 +1174,10 @@ class ThreadLocalFileContext(FileLockContext, local): "ContextErrorPolicy", "FileLockContext", "FileLockMeta", + "_append_exception_context", "_canonical", + "_grouped_errors", "_raise_body_and_release", + "_raise_chained_errors", "_raise_grouped_errors", ] diff --git a/src/filelock/_async.py b/src/filelock/_async.py new file mode 100644 index 00000000..357947cb --- /dev/null +++ b/src/filelock/_async.py @@ -0,0 +1,158 @@ +"""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, TypeVar, cast + +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 # noqa: TRY301 # 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: # noqa: BLE001 # backend control-flow exceptions are operation results + return _BackendOutcome(error=error) + + +async def _capture_awaitable(awaitable: Awaitable[_T]) -> _BackendOutcome[_T]: + try: + return _BackendOutcome(value=await awaitable) + except BaseException as error: # noqa: BLE001 # backend cancellation must remain distinct from caller cancellation + return _BackendOutcome(error=error) + + +__all__ = [ + "_AsyncTransitionGate", + "_AsyncTransitionUnavailableError", + "_BackendOutcome", + "_capture_awaitable", + "_capture_call", + "_drain_future", + "_future_result", + "_wait_until_done", +] diff --git a/src/filelock/_async_read_write.py b/src/filelock/_async_read_write.py index 4622a3b7..b89693ef 100644 --- a/src/filelock/_async_read_write.py +++ b/src/filelock/_async_read_write.py @@ -6,8 +6,10 @@ import functools from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ParamSpec, TypeVar +from ._api import _append_exception_context, _raise_chained_errors +from ._async import _BackendOutcome, _capture_call, _drain_future, _future_result, _wait_until_done from ._read_write import ReadWriteLock if TYPE_CHECKING: @@ -15,6 +17,12 @@ from collections.abc import AsyncGenerator, Callable from concurrent import futures from types import TracebackType + from typing import NoReturn + + from ._api import AcquireReturnProxy + +_P = ParamSpec("_P") +_R = TypeVar("_R") class AsyncReadWriteLock: @@ -95,10 +103,14 @@ async def read_lock(self, timeout: float | None = None, *, blocking: bool | None 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() + await self._release_in_context(body_error) @asynccontextmanager async def write_lock(self, timeout: float | None = None, *, blocking: bool | None = None) -> AsyncGenerator[None]: @@ -116,10 +128,22 @@ async def write_lock(self, timeout: float | None = None, *, blocking: bool | Non 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: """ @@ -135,8 +159,11 @@ async def acquire_read(self, timeout: float = -1, *, blocking: bool = True) -> A :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds + If rollback fails, peers may remain blocked. A later acquisition first retries that cleanup; + ``release(force=True)`` retries it without acquiring. + """ - await self._run(self._lock.acquire_read, timeout, blocking=blocking) + 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: @@ -153,8 +180,11 @@ async def acquire_write(self, timeout: float = -1, *, blocking: bool = True) -> :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 + If rollback fails, peers may remain blocked. A later acquisition first retries that cleanup; + ``release(force=True)`` retries it without acquiring. + """ - await self._run(self._lock.acquire_write, timeout, blocking=blocking) + 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: @@ -163,7 +193,8 @@ async def release(self, *, force: bool = False) -> None: See :meth:`ReadWriteLock.release` for full semantics. - :param force: if ``True``, release the lock completely regardless of the current lock level + :param force: if ``True``, release the lock completely regardless of the current lock level and retry cleanup + from a failed acquisition :raises RuntimeError: if no lock is currently held and *force* is ``False`` @@ -177,14 +208,71 @@ async def close(self) -> None: After calling this method, the lock instance is no longer usable. """ - await self._run(self._lock.close) + 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: # noqa: BLE001 # reported with the cancellation below + self._raise_cancelled_error(cancellation, error) + self._shutdown_owned_executor() + raise + _future_result(close_future) + self._shutdown_owned_executor() + + 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 asyncio.CancelledError as acquire_error: + self._raise_cancelled_error(cancellation, acquire_error) + except BaseException as error: # noqa: BLE001 # reported with the cancellation below + self._raise_cancelled_error(cancellation, error) + try: + await _drain_future(self._submit(self._lock.release)) + except BaseException as error: # noqa: BLE001 # reported with the cancellation below + self._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: # noqa: BLE001 # reported with the cancellation below + self._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), + ) + + @staticmethod + def _raise_cancelled_error(cancellation: asyncio.CancelledError, error: BaseException) -> NoReturn: + 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) + + def _shutdown_owned_executor(self) -> None: if self._owns_executor: self._executor.shutdown(wait=False) - async def _run(self, func: Callable[..., object], *args: object, **kwargs: object) -> object: - loop = self._loop or asyncio.get_running_loop() - return await loop.run_in_executor(self._executor, functools.partial(func, *args, **kwargs)) - 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. diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py index 6a23c56a..5cf338e0 100644 --- a/src/filelock/_read_write.py +++ b/src/filelock/_read_write.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Final, Literal from weakref import WeakValueDictionary -from ._api import AcquireReturnProxy +from ._api import AcquireReturnProxy, _raise_chained_errors from ._error import Timeout if TYPE_CHECKING: @@ -154,6 +154,10 @@ def acquire_read(self, timeout: float = -1, *, blocking: bool = True) -> Acquire :raises RuntimeError: if a write lock is already held on this instance :raises Timeout: if the lock cannot be acquired within *timeout* seconds + If cleanup from a previous failed acquisition also failed, this method retries that rollback before starting a + new transaction. Until cleanup succeeds, other processes may remain blocked. :meth:`release` with ``force=True`` + retries the same cleanup without acquiring. + """ return self._acquire("read", timeout, blocking=blocking) @@ -174,6 +178,10 @@ def acquire_write(self, timeout: float = -1, *, blocking: bool = True) -> Acquir :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 + If cleanup from a previous failed acquisition also failed, this method retries that rollback before starting a + new transaction. Until cleanup succeeds, other processes may remain blocked. :meth:`release` with ``force=True`` + retries the same cleanup without acquiring. + """ return self._acquire("write", timeout, blocking=blocking) @@ -225,35 +233,34 @@ def release(self, *, force: bool = False) -> None: 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 + :param force: if ``True``, release the lock completely regardless of the current lock level and retry cleanup + from a failed acquisition :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ - should_rollback = False - with self._internal_lock: + with self._transaction_lock, self._internal_lock: if self._lock_level == 0: - if force: + if force and not self._con.in_transaction: return - msg = f"Cannot release a lock on {self.lock_file} (lock id: {id(self)}) that is not held" - raise RuntimeError(msg) - if force: - self._lock_level = 0 - else: + 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 - if self._lock_level == 0: - self._current_mode = None - self._write_thread_id = None - should_rollback = True - if should_rollback: - # The rollback ends the transaction on the shared connection, so it has to be serialized against - # acquire()'s BEGIN the same way acquire() already serializes itself with _transaction_lock. Without - # this, another thread that sees lock_level back at 0 can start its BEGIN while this rollback's - # transaction is still open (raising "cannot start a transaction within a transaction") or, in the - # other ordering, have its freshly started transaction rolled back here, dropping the database lock - # while it still believes it holds it. - with self._transaction_lock: + return + try: self._con.rollback() + except sqlite3.Error: + if not self._con.in_transaction: + self._clear_lock_state() + raise + self._clear_lock_state() + + def _clear_lock_state(self) -> None: + self._lock_level = 0 + self._current_mode = None + self._write_thread_id = None def close(self) -> None: """ @@ -276,19 +283,6 @@ def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: self._acquire_transaction_lock(blocking=blocking, timeout=timeout) try: return self._do_acquire_inner(mode, timeout, blocking=blocking, start_time=start_time) - except sqlite3.Error as exc: - # A read acquire runs BEGIN (a deferred transaction that takes no database lock) and only then the - # SELECT that takes the SHARED lock. If a writer grabs the EXCLUSIVE lock between the two, the SELECT - # fails but BEGIN's transaction is left open on the shared connection. Roll it back here, while we still - # hold _transaction_lock, otherwise the next acquire's BEGIN dies with "cannot start a transaction - # within a transaction" and the instance is wedged for good. Catch only sqlite3.Error: a - # reentrant-validation RuntimeError from _do_acquire_inner's double-check must not roll back, or it - # would drop a transaction another thread holds on the shared connection. - with suppress(sqlite3.Error): - self._con.rollback() - if isinstance(exc, sqlite3.OperationalError) and "database is locked" in str(exc): - raise Timeout(self.lock_file) from None - raise finally: self._transaction_lock.release() @@ -304,7 +298,24 @@ def _do_acquire_inner( with self._internal_lock: if self._lock_level > 0: return self._validate_reentrant(mode) - self._configure_and_begin(mode, timeout, blocking=blocking, start_time=start_time) + if self._con.in_transaction: + self._con.rollback() + try: + self._configure_and_begin(mode, timeout, blocking=blocking, start_time=start_time) + except sqlite3.Error 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 + if self._con.in_transaction: + try: + self._con.rollback() + except sqlite3.Error as rollback_error: + _raise_chained_errors(acquisition_error, rollback_error) + if acquisition_error is not error: + raise acquisition_error from None + raise with self._internal_lock: self._current_mode = mode self._lock_level = 1 diff --git a/src/filelock/asyncio.py b/src/filelock/asyncio.py index 481d2367..a7570b74 100644 --- a/src/filelock/asyncio.py +++ b/src/filelock/asyncio.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from inspect import iscoroutinefunction from threading import local -from typing import TYPE_CHECKING, Any, Final, NoReturn, TypeVar +from typing import TYPE_CHECKING, Final, NoReturn, TypeVar, cast from ._api import ( _UNSET_FILE_MODE, @@ -19,8 +19,22 @@ ContextErrorPolicy, FileLockContext, FileLockMeta, + _append_exception_context, _canonical, + _grouped_errors, _raise_body_and_release, + _raise_chained_errors, + _raise_grouped_errors, +) +from ._async import ( + _AsyncTransitionGate, + _AsyncTransitionUnavailableError, + _BackendOutcome, + _capture_awaitable, + _capture_call, + _drain_future, + _future_result, + _wait_until_done, ) from ._error import Timeout from ._soft import SoftFileLock @@ -29,7 +43,7 @@ if TYPE_CHECKING: import sys - from collections.abc import Callable + from collections.abc import Callable, Coroutine from concurrent import futures from types import TracebackType @@ -40,6 +54,10 @@ _LOGGER: Final[logging.Logger] = logging.getLogger("filelock") +_ASYNC_RELEASE_CANCELLATION_ERRORS: Final[str] = "lock release cancellation and backend release both failed" +_ASYNC_CONTEXT_RELEASE_ERRORS: Final[str] = "context body, release cancellation, and backend release failed" +_ASYNC_RELEASE_CANCELLATION_MARKER_ATTR: Final[str] = "_filelock_async_release_cancellation" +_ASYNC_RELEASE_CANCELLATION_MARKER: Final[list[None]] = [] _AT = TypeVar("_AT", bound="BaseAsyncFileLock") @@ -175,21 +193,18 @@ def __init__( # noqa: PLR0913 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() - # External code goes through this class's properties, not the context directly. - kwargs: dict[str, Any] = { - "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, - } self._context: AsyncFileLockContext = (AsyncThreadLocalFileContext if thread_local else AsyncFileLockContext)( - **kwargs + 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, ) @property @@ -265,25 +280,50 @@ async def acquire( # ty: ignore[invalid-method-override] if poll_interval is None: poll_interval = self._context.poll_interval - # Bump early; _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) - + start_time = time.perf_counter() try: - await self._async_poll_until_acquired( + return await self._acquire_with_admission( blocking=blocking, cancel_check=cancel_check, timeout=timeout, poll_interval=poll_interval, - start_time=time.perf_counter(), + start_time=start_time, ) - except BaseException: - self._reconcile_failed_acquire(canonical) - raise - self._commit_acquire(canonical) - return AsyncAcquireReturnProxy(lock=self) + 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) + 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 + self._commit_acquire(canonical) + return AsyncAcquireReturnProxy(lock=self) async def _async_poll_until_acquired( self, @@ -300,7 +340,7 @@ async def _async_poll_until_acquired( 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_internal_method(self._acquire) + await self._run_acquire_attempt() if self.is_locked: _LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename) return @@ -316,6 +356,81 @@ async def _async_poll_until_acquired( _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) + 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: # noqa: BLE001 # reported with the cancellation below + acquire_error = error + + rollback_error: BaseException | None = None + if self.is_locked: + try: + await _drain_future(self._start_internal_method(self._release)) + except BaseException as error: # noqa: BLE001 # reported with the cancellation below + rollback_error = error + + if acquire_error is not None: + if rollback_error is not None: + 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_internal_method(self._release)) + except BaseException as rollback_error: # noqa: BLE001 # 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] # noqa: FBT001, FBT002 """ Release the file lock. The lock is only completely released when the lock counter reaches 0. The lock file @@ -324,6 +439,12 @@ async def release(self, force: bool = False) -> None: # ty: ignore[invalid-meth :param force: If true, the lock counter is ignored and the lock is released in every case. """ + if 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: @@ -332,17 +453,20 @@ async def release(self, force: bool = False) -> None: # ty: ignore[invalid-meth lock_id, lock_filename = id(self), self.lock_file _LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename) - # Run release as its own task and shield it: a cancellation arriving mid-release must not stop the state - # transition halfway. On cancellation, wait for the task to finish before letting the cancel reach the caller. - release_task = asyncio.ensure_future(self._run_internal_method(self._release)) + release_future = self._start_internal_method(self._release) try: - await asyncio.shield(release_task) - except asyncio.CancelledError: - with contextlib.suppress(Exception): - await release_task - self._commit_release_if_released() + await _wait_until_done(release_future) + except asyncio.CancelledError as cancellation: + try: + await _drain_future(release_future) + except BaseException as release_error: # noqa: BLE001 # 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 - except Exception: + 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() @@ -354,13 +478,18 @@ def _commit_release_if_released(self) -> None: if not self.is_locked: self._commit_release() - async def _run_internal_method(self, method: Callable[[], Any]) -> None: + def _start_internal_method( + self, method: Callable[[], None] | Callable[[], Coroutine[None, None, None]] + ) -> asyncio.Future[_BackendOutcome[None]]: if iscoroutinefunction(method): - await method() - elif self.run_in_executor: - await asyncio.get_running_loop().run_in_executor(self.executor, method) - else: - 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 __enter__(self) -> NoReturn: """Sync context manager entry is not supported because lock acquisition is a coroutine.""" @@ -371,7 +500,7 @@ def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, - traceback: object, + 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." @@ -410,8 +539,21 @@ async def _release_in_context( # ty: ignore[invalid-method-override] try: await self.release() except BaseException as release_error: - if body_error is None or self._context_error_policy == "chain": + 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: + case (asyncio.CancelledError() as cancellation, backend_error): + _raise_grouped_errors(_ASYNC_CONTEXT_RELEASE_ERRORS, body_error, cancellation, backend_error) _raise_body_and_release(body_error, release_error) def __del__(self) -> None: diff --git a/tests/async_filelock_cancellation_helpers.py b/tests/async_filelock_cancellation_helpers.py new file mode 100644 index 00000000..a3b33c67 --- /dev/null +++ b/tests/async_filelock_cancellation_helpers.py @@ -0,0 +1,69 @@ +from __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: + 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" + finally: + if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails + probe.terminate() + probe.join(timeout=5) + assert (probe.exitcode, acquired.value) == (0, available) + + +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: + pass + + +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"] diff --git a/tests/read_write_helpers.py b/tests/read_write_helpers.py new file mode 100644 index 00000000..4df2db74 --- /dev/null +++ b/tests/read_write_helpers.py @@ -0,0 +1,38 @@ +from __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" + finally: + if probe.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails + probe.terminate() + probe.join(timeout=5) + assert (probe.exitcode, acquired.value) == (0, available) + + +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"] diff --git a/tests/test_async_filelock.py b/tests/test_async_filelock.py index 41b9b644..ca4c504a 100644 --- a/tests/test_async_filelock.py +++ b/tests/test_async_filelock.py @@ -6,7 +6,6 @@ import os import sys import threading -import time import traceback from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager @@ -16,7 +15,14 @@ import pytest -from filelock import AsyncFileLock, AsyncSoftFileLock, BaseAsyncFileLock, CloseErrorPolicy, ContextErrorPolicy, Timeout +from filelock import ( + AsyncFileLock, + AsyncSoftFileLock, + BaseAsyncFileLock, + CloseErrorPolicy, + ContextErrorPolicy, + Timeout, +) if sys.version_info >= (3, 11): # pragma: no cover (py311+) from builtins import BaseExceptionGroup, ExceptionGroup @@ -588,22 +594,6 @@ async def test_release_keeps_lock_held_when_unlock_fails(tmp_path: Path, mocker: assert not lock.is_locked -@_UNIX_FLOCK_ONLY -@pytest.mark.asyncio -async def test_release_completes_despite_cancellation(tmp_path: Path, mocker: MockerFixture) -> None: - lock = AsyncFileLock(str(tmp_path / "a")) - await lock.acquire() - # A slow unlock lets the cancellation land mid-release; shield must still drive it to completion. - mocker.patch("filelock._unix.fcntl.flock", side_effect=lambda _fd, _op: time.sleep(0.2)) - task = asyncio.ensure_future(lock.release()) - await asyncio.sleep(0.05) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - assert not lock.is_locked - assert lock.lock_counter == 0 - - @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) @@ -886,6 +876,39 @@ async def test_context_group_base_exception_leaf_is_base_group( 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: # 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 diff --git a/tests/test_async_filelock_acquire_cancellation.py b/tests/test_async_filelock_acquire_cancellation.py new file mode 100644 index 00000000..4e2a0af4 --- /dev/null +++ b/tests/test_async_filelock_acquire_cancellation.py @@ -0,0 +1,478 @@ +from __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, Final + +import pytest +from async_filelock_cancellation_helpers import assert_cancellation_message, assert_file_lock_state + +from filelock import ( + AsyncFileLock, + BaseAsyncFileLock, + ContextErrorPolicy, +) + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from builtins import BaseExceptionGroup, ExceptionGroup +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 +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 # noqa: B904 # 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.asyncio +async def test_acquire_cancellation_does_not_release_later_claim(tmp_path: Path) -> 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) + + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.asyncio +async def test_cancelled_queued_acquire_does_not_claim_transition(tmp_path: Path) -> 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) + + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.asyncio +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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +@pytest.mark.asyncio +async def test_acquire_cancellation_surfaces_attempt_error_after_rollback( + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize( + ("context_message", "preserved"), + [pytest.param("unrelated", True, id="distinct"), pytest.param("attempt", False, id="equivalent")], +) +@pytest.mark.asyncio +async def test_acquire_cancellation_group_reconciles_attempt_context( + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +@pytest.mark.asyncio +async def test_acquire_cancellation_surfaces_rollback_error( + 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() + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +@pytest.mark.asyncio +async def test_acquire_cancellation_surfaces_attempt_and_rollback_errors( + 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) diff --git a/tests/test_async_filelock_release_cancellation.py b/tests/test_async_filelock_release_cancellation.py new file mode 100644 index 00000000..6d7b3121 --- /dev/null +++ b/tests/test_async_filelock_release_cancellation.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import asyncio +import logging +import sys +import threading +from errno import EIO +from typing import TYPE_CHECKING, Final + +import pytest +from async_filelock_cancellation_helpers import ( + assert_cancellation_message, + assert_file_lock_state, + get_fcntl, + start_file_lock_holder, +) + +from filelock import AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from builtins import BaseExceptionGroup, ExceptionGroup +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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.asyncio +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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.asyncio +async def test_release_waits_for_provisional_acquire(tmp_path: Path) -> 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) + + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +@pytest.mark.asyncio +async def test_release_cancellation_surfaces_backend_error( + 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() diff --git a/tests/test_async_filelock_runner_shutdown.py b/tests/test_async_filelock_runner_shutdown.py new file mode 100644 index 00000000..3ca947cc --- /dev/null +++ b/tests/test_async_filelock_runner_shutdown.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from errno import EIO +from queue import Queue +from typing import TYPE_CHECKING, Final, TypeVar + +import pytest +from async_filelock_cancellation_helpers import assert_file_lock_state, get_fcntl + +from filelock import AsyncAcquireReturnProxy, AsyncFileLock, ContextErrorPolicy + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from builtins import BaseExceptionGroup +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) + + +@_UNIX_FLOCK_ONLY +def test_runner_shutdown_waits_for_executor_acquire_rollback(tmp_path: Path) -> None: + 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) + + +@_UNIX_FLOCK_ONLY +@pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +def test_runner_shutdown_preserves_body_cancellation_and_release_errors( + 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( + 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( + 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( + 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()) diff --git a/tests/test_async_filelock_transition_admission.py b/tests/test_async_filelock_transition_admission.py new file mode 100644 index 00000000..7c58c9ed --- /dev/null +++ b/tests/test_async_filelock_transition_admission.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from typing import TYPE_CHECKING, Final + +import pytest +from async_filelock_cancellation_helpers import ( + assert_cancellation_message, + assert_file_lock_state, + start_file_lock_holder, +) + +from filelock import AsyncFileLock, Timeout + +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 +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 +async def test_queued_acquire_proceeds_after_prior_waiter_cancels(tmp_path: Path) -> 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) + + 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: + with pytest.raises(asyncio.CancelledError) as info: + 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]: + started = asyncio.Event() + + async def acquire() -> None: + started.set() + await lock.acquire() + + return asyncio.create_task(acquire()), started diff --git a/tests/test_async_read_write.py b/tests/test_async_read_write.py index fb751e7d..8fb43ea8 100644 --- a/tests/test_async_read_write.py +++ b/tests/test_async_read_write.py @@ -10,8 +10,7 @@ import sqlite3 -from filelock import AsyncReadWriteLock, Timeout -from filelock._read_write import ReadWriteLock +from filelock import AsyncReadWriteLock, ReadWriteLock, Timeout if TYPE_CHECKING: from collections.abc import Generator @@ -33,20 +32,19 @@ def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") -@pytest.mark.parametrize("mode", ["read", "write"]) +@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()) - assert lock._lock._lock_level == 1 - assert lock._lock._current_mode == mode - await lock.release() - assert lock._lock._lock_level == 0 - assert lock._lock._current_mode is None - assert isinstance(proxy, object) + 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", ["read", "write"]) +@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) @@ -58,7 +56,7 @@ async def test_lock_context_manager(lock_file: str, mode: Literal["read", "write assert lock._lock._current_mode is None -@pytest.mark.parametrize("mode", ["read", "write"]) +@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) @@ -94,7 +92,7 @@ async def test_mode_change_prohibited( await lock.release() -@pytest.mark.parametrize("mode", ["read", "write"]) +@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) @@ -232,7 +230,7 @@ async def test_acquire_return_proxy_context_manager(lock_file: str) -> None: assert lock._lock._lock_level == 0 -@pytest.mark.parametrize("mode", ["read", "write"]) +@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) diff --git a/tests/test_async_read_write_cancellation.py b/tests/test_async_read_write_cancellation.py new file mode 100644 index 00000000..21108722 --- /dev/null +++ b/tests/test_async_read_write_cancellation.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import asyncio +import multiprocessing +import sqlite3 +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Literal + +import pytest +from async_filelock_cancellation_helpers import assert_cancellation_message +from read_write_helpers import assert_read_write_lock_state + +from filelock import AsyncReadWriteLock, ReadWriteLock + +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(lock_file, 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")], +) +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() + process.join(timeout=10) + if process.is_alive(): # pragma: no cover - cleanup for a hung child after the assertion fails + process.terminate() + process.join(timeout=5) + + assert (process.exitcode, canceled.value) == (0, True) + + +@pytest.mark.asyncio +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 +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( + lock_file, 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(lock_file, 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 +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( + lock_file, 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.asyncio +async def test_close_cancellation_shuts_down_owned_executor(lock_file: str, mocker: MockerFixture) -> None: + rollback_started = asyncio.Event() + finish_rollback = threading.Event() + _patch_async_rollback(lock_file, 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.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) + with pytest.raises(RuntimeError): + executor.submit(int) + + +@pytest.mark.parametrize("operation", [pytest.param("release", id="release"), pytest.param("close", id="close")]) +@pytest.mark.asyncio +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( + lock_file, 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 +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( + lock_file, + 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( + lock_file: str, + mocker: MockerFixture, + loop: asyncio.AbstractEventLoop, + rollback_started: asyncio.Event, + finish_rollback: threading.Event, +) -> None: + real_connection = sqlite3.connect(lock_file, check_same_thread=False) + + def rollback() -> None: + loop.call_soon_threadsafe(rollback_started.set) + assert finish_rollback.wait(timeout=5) + real_connection.rollback() + + _patch_async_connection(mocker, real_connection, 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): + 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( + lock_file: str, + mocker: MockerFixture, + loop: asyncio.AbstractEventLoop, + execute_started: asyncio.Event, +) -> None: + real_connection = sqlite3.connect(lock_file, check_same_thread=False) + + def execute(statement: str) -> sqlite3.Cursor: + if statement.startswith("PRAGMA journal_mode"): + loop.call_soon_threadsafe(execute_started.set) + return real_connection.execute(statement) + + _patch_async_connection(mocker, real_connection, execute=execute) + + +def _patch_async_rollback_failure( + lock_file: str, + mocker: MockerFixture, + loop: asyncio.AbstractEventLoop, + rollback_started: asyncio.Event, + finish_rollback: threading.Event, + *, + prior_context: BaseException | None = None, +) -> sqlite3.OperationalError: + real_connection = sqlite3.connect(lock_file, check_same_thread=False) + rollback_error = sqlite3.OperationalError("rollback failed") + rollback_error.__context__ = prior_context + rollback_failed = False + + def rollback() -> 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, real_connection, rollback=rollback) + return rollback_error + + +def _patch_async_acquire_and_rollback_failure( + lock_file: str, + mocker: MockerFixture, + loop: asyncio.AbstractEventLoop, + acquire_started: asyncio.Event, + finish_acquire: threading.Event, + mode: Literal["read", "write"], +) -> tuple[sqlite3.OperationalError, sqlite3.OperationalError, LookupError]: + real_connection = sqlite3.connect(lock_file, check_same_thread=False) + 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 execute(statement: str) -> sqlite3.Cursor: + nonlocal acquire_failed + cursor = real_connection.execute(statement) + target = statement.startswith("SELECT") if mode == "read" else statement.startswith("BEGIN EXCLUSIVE") + 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() -> None: + nonlocal rollback_failed + if not rollback_failed: + rollback_failed = True + try: + raise prior_rollback_error + except LookupError: + raise rollback_error # noqa: B904 # exercise implicit backend context preservation + real_connection.rollback() + + _patch_async_connection(mocker, real_connection, execute=execute, rollback=rollback) + return acquire_error, rollback_error, prior_rollback_error + + +def _patch_async_connection( + mocker: MockerFixture, + real_connection: sqlite3.Connection, + *, + execute: Callable[[str], sqlite3.Cursor] | None = None, + rollback: Callable[[], None] | None = None, +) -> None: + configuration: dict[str, Callable[[str], sqlite3.Cursor] | Callable[[], None]] = {} + if execute is not None: + configuration["execute.side_effect"] = execute + if rollback is not None: + configuration["rollback.side_effect"] = rollback + connection = mocker.MagicMock(spec_set=sqlite3.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, + ) + sqlite_module = mocker.MagicMock( + spec_set=sqlite3, + Error=sqlite3.Error, + OperationalError=sqlite3.OperationalError, + connect=mocker.create_autospec(sqlite3.connect, return_value=connection), + ) + mocker.patch("filelock._read_write.sqlite3", sqlite_module) + + +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() diff --git a/tests/test_filelock.py b/tests/test_filelock.py index 757463d5..9f68e1db 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -1640,6 +1640,75 @@ def test_context_group_detaches_release_context( ) == ((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 # noqa: B904 # 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 # noqa: B904 # 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") @pytest.mark.parametrize( "use_proxy", diff --git a/tests/test_read_write.py b/tests/test_read_write.py index a6efeb53..6b5166ef 100644 --- a/tests/test_read_write.py +++ b/tests/test_read_write.py @@ -9,8 +9,11 @@ pytest.importorskip("sqlite3") -from filelock import Timeout -from filelock._read_write import ReadWriteLock +import sqlite3 + +from read_write_helpers import assert_read_write_lock_state + +from filelock import ReadWriteLock, Timeout if TYPE_CHECKING: from collections.abc import Generator @@ -18,6 +21,8 @@ from multiprocessing.synchronize import Event as EventType from pathlib import Path + from pytest_mock import MockerFixture + @pytest.mark.timeout(20) def test_read_locks_are_shared(lock_file: str) -> None: @@ -320,6 +325,84 @@ def test_sequential_lock_modes( pass +@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(lock_file, 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(lock_file: str, mocker: MockerFixture, *, after_rollback: bool) -> sqlite3.OperationalError: + real_connection = sqlite3.connect(lock_file, check_same_thread=False) + rollback_error = sqlite3.OperationalError("rollback failed") + failed = False + + def fail_first_rollback() -> None: + nonlocal failed + if failed: + real_connection.rollback() + return + failed = True + if after_rollback: + real_connection.rollback() + raise rollback_error + + connection = mocker.MagicMock( + spec_set=sqlite3.Connection, + wraps=real_connection, + **{"rollback.side_effect": fail_first_rollback}, + ) + mocker.patch.object( + type(connection), + "in_transaction", + new_callable=mocker.PropertyMock, + create=True, + side_effect=lambda: real_connection.in_transaction, + ) + sqlite_module = mocker.MagicMock( + spec_set=sqlite3, + Error=sqlite3.Error, + OperationalError=sqlite3.OperationalError, + connect=mocker.create_autospec(sqlite3.connect, return_value=connection), + ) + mocker.patch("filelock._read_write.sqlite3", sqlite_module) + return rollback_error + + @pytest.fixture def lock_file(tmp_path: Path) -> str: return str(tmp_path / "test_lock.db") From 04546bf0eaf257820d36bfb41eede87ac0bc16ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 14 Jul 2026 06:43:03 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(fork):=20preserve=20pare?= =?UTF-8?q?nt=20ownership=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forked child inherits a parent's descriptors and Python lock state. Releasing an inherited native lock can unlock the shared open-file description, while releasing a soft lock can unlink the parent's marker. Same-path non-singleton soft read/write locks could also disappear from the fork registry before child cleanup. This caused the ownership violation reported in [#634](https://github.com/tox-dev/filelock/issues/634). Track held and provisional descriptors by object identity and creator PID. The child checks the descriptor identity before closing it, without issuing an OS unlock or removing a marker, then clears the inherited in-memory lock state. An unavailable identity leaves the descriptor untouched because an earlier child callback may have reused its number. Coordinate descriptor transitions with fork callbacks across threads and concurrent coroutine tasks. Registration and rollback failures remain observable, including cancellation paths inherited from [#652](https://github.com/tox-dev/filelock/pull/652). Reentrant singleton construction during a parent callback reports a `RuntimeError`. Child cache insertion rejects an instance whose construction crossed the fork boundary. This branch depends on #652 so fork tracking can wrap its cancellation-safe async transitions. Rebase it onto `main` after merging #652. Fixes #634. --- docs/changelog/634.bugfix.rst | 6 + pyproject.toml | 1 + src/filelock/_api.py | 535 ++++++++++++++- src/filelock/_soft.py | 6 +- src/filelock/_soft_rw/_async.py | 23 +- src/filelock/_soft_rw/_sync.py | 160 +++-- src/filelock/_unix.py | 27 +- src/filelock/_windows.py | 9 +- src/filelock/asyncio.py | 116 +++- tests/fork_helpers.py | 59 ++ tests/test_fork_backends.py | 506 ++++++++++++++ tests/test_fork_coordination.py | 1103 +++++++++++++++++++++++++++++++ tests/test_fork_ownership.py | 566 ++++++++++++++++ tests/test_fork_registries.py | 87 +++ tests/test_unix_fallback.py | 18 + 15 files changed, 3107 insertions(+), 115 deletions(-) create mode 100644 docs/changelog/634.bugfix.rst create mode 100644 tests/fork_helpers.py create mode 100644 tests/test_fork_backends.py create mode 100644 tests/test_fork_coordination.py create mode 100644 tests/test_fork_ownership.py create mode 100644 tests/test_fork_registries.py diff --git a/docs/changelog/634.bugfix.rst b/docs/changelog/634.bugfix.rst new file mode 100644 index 00000000..1aea12bb --- /dev/null +++ b/docs/changelog/634.bugfix.rst @@ -0,0 +1,6 @@ +Preserve parent lock ownership across ``fork()``. A child closes an inherited descriptor only while its identity still +matches, so it neither unlocks nor unlinks the parent's lock. It clears inherited ownership state and singleton caches, +then requires a new lock instance before acquiring. Fork-time construction replaces inherited cache mutexes and omits +an instance whose construction crossed into the child from the cache. If ``fstat()`` cannot establish the identity, +child cleanup skips the descriptor number because an earlier fork callback may have reused it. Descriptor tracking +also covers third-party backends that fail after assigning a descriptor. diff --git a/pyproject.toml b/pyproject.toml index a012cb25..bba01e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,7 @@ run.concurrency = [ ] run.parallel = true run.patch = [ + "_exit", "subprocess", ] run.plugins = [ diff --git a/src/filelock/_api.py b/src/filelock/_api.py index a9e24c0e..d347f423 100644 --- a/src/filelock/_api.py +++ b/src/filelock/_api.py @@ -10,8 +10,10 @@ import warnings from abc import ABCMeta, abstractmethod from collections.abc import Callable +from contextlib import contextmanager from dataclasses import dataclass -from threading import Lock, local +from itertools import count, starmap +from threading import Condition, Lock, RLock, get_ident, local from typing import TYPE_CHECKING, Final, Literal, NoReturn, TypeVar, cast from weakref import WeakKeyDictionary, WeakValueDictionary @@ -31,18 +33,40 @@ _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], ...]: ... + + class _ForkResettableClass(Protocol): + @classmethod + def _reset_class_after_fork(cls) -> 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 ( 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 @@ -277,7 +313,8 @@ def __init__(self) -> None: class FileLockMeta(ABCMeta): _instances: WeakValueDictionary[str, BaseFileLock] - _instances_lock: Lock + _instances_lock: RLock + _instances_under_construction: set[str] def __call__( # noqa: PLR0913 cls: type[_T], @@ -297,6 +334,7 @@ def __call__( # noqa: PLR0913 on_acquired: Callable[[int], None] | None = None, **kwargs: _ExtraValue, ) -> _T: + _ensure_current_process() lifetime = _resolve_lifetime(lifetime, supported=cls._lifetime_supported, cls_name=cls.__name__) # Validate before building the instance: a raise inside __init__ would leave a half-constructed object whose # __del__ then trips over the missing context. @@ -334,7 +372,19 @@ def __call__( # noqa: PLR0913 singleton_key = _canonical(lock_file) with cls._instances_lock: if (instance := cls._instances.get(singleton_key)) is None: - instance = cls._create_instance(lock_file, params) + if singleton_key in cls._instances_under_construction: + 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: + msg = "Lock construction cannot continue after fork; construct a new lock in the child" + raise RuntimeError(msg) cls._instances[singleton_key] = instance return instance @@ -393,12 +443,11 @@ def _create_instance( _INIT_PARAMETER_MODELS: Final[WeakKeyDictionary[type[BaseFileLock], _InitParameterModel]] = WeakKeyDictionary() -_INIT_PARAMETER_MODELS_LOCK: Final[Lock] = Lock() def _init_parameter_model(cls: type[BaseFileLock]) -> _InitParameterModel: # A strong cache would keep dynamically created subclasses alive for the process lifetime. - with _INIT_PARAMETER_MODELS_LOCK: + 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( @@ -512,7 +561,8 @@ class BaseFileLock(contextlib.ContextDecorator, metaclass=FileLockMeta): # noqa """ _instances: WeakValueDictionary[str, BaseFileLock] - _instances_lock: Lock + _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" @@ -533,7 +583,15 @@ 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 = Lock() + cls._instances_lock = RLock() + cls._instances_under_construction = set() + _register_fork_class(cls) + + @classmethod + def _reset_class_after_fork(cls) -> None: # pragma: no cover - exercised in fork children + cls._instances = WeakValueDictionary() + cls._instances_lock = RLock() + cls._instances_under_construction = set() def __init__( # noqa: PLR0913 self, @@ -605,6 +663,7 @@ def __init__( # noqa: PLR0913 and re-raises. :class:`SoftFileLock` rejects the hook. """ + 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 @@ -621,6 +680,7 @@ def __init__( # noqa: PLR0913 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""" @@ -819,11 +879,13 @@ def is_locked(self) -> bool: 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: @@ -857,6 +919,8 @@ def _release_in_context(self, body_error: BaseException | None) -> None: 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: no cover - exercised in fork children self.release(force=True) def acquire( @@ -904,6 +968,7 @@ def acquire( without side effects. """ + self._raise_if_inherited() if timeout is None: timeout = self._context.timeout @@ -946,7 +1011,7 @@ def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002 :param force: If true, the lock counter is ignored and the lock is released in every case. """ - if not self.is_locked: + 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 @@ -955,7 +1020,7 @@ def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002 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() + 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. @@ -966,6 +1031,44 @@ def release(self, force: bool = False) -> None: # noqa: FBT001, FBT002 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: no cover - exercised only in fork children + 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: no cover - exercised in fork children + 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], ...]: + 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 on the current thread/task. @@ -994,10 +1097,12 @@ def _poll_until_acquired( 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) - self._acquire() + 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 @@ -1024,26 +1129,105 @@ def _reconcile_failed_acquire(self, canonical: str) -> None: self._undo_acquire() def _invoke_on_acquired(self) -> None: - # Runs inside the backend _acquire (in the executor for async locks), once the native lock is held and the - # descriptor is set, before acquire() commits the registry. Fires once per physical acquisition; a recursive - # acquire never reaches here because the poll loop skips _acquire while the lock is held. + # 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__ - # Undo the native acquisition so the failed hook does not leave the lock held. Call the backend _release - # directly rather than the public release(): the counter and thread-local deadlock registry are reconciled - # by acquire() on the owning thread, and the async release() is a coroutine this synchronous path cannot - # await. A release that also fails surfaces both errors; is_locked then tells acquire() how to reconcile. try: - self._release() + self._release_with_fork_tracking() except BaseException as release_error: # noqa: BLE001 # 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: + 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: # noqa: BLE001 # 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: # noqa: BLE001 # pragma: no cover - allocation/control-flow during fallback + tracking_error = error + try: + self._release_with_fork_tracking() + except BaseException as rollback_error: # noqa: BLE001 # 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: + _raise_cleanup_errors( + "lock acquisition cleanup failed", acquisition_error, registration_error, tracking_error + ) + + def _rollback_failed_registration(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: # noqa: BLE001 # pragma: no cover - allocation/control-flow during fallback + tracking_error = error + try: + self._release_with_fork_tracking() + except BaseException as rollback_error: # noqa: BLE001 # 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: + 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_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: + _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) @@ -1155,6 +1339,18 @@ class FileLockContext: #: 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 @@ -1166,6 +1362,302 @@ 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(Lock()) + 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[type[_ForkResettableClass], ...]]] = {} + self.provisional_descriptor_tokens: dict[int, list[tuple[int, ...]]] = {} + self.pid = os.getpid() + + def reset_synchronization(self) -> None: # pragma: no cover - exercised in fork children + self.gate = Condition(Lock()) + 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, type[_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: no cover - platform without register_at_fork + sys.addaudithook(_audit_fork_safety) + _REGISTER_AT_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: no cover - platform without register_at_fork + return # pragma: no cover - platform without register_at_fork + _ensure_current_process() + creator_pid = os.getpid() + token = _enter_fork_transition(descriptor_owner) + try: + yield + finally: + if os.getpid() == creator_pid: + _leave_fork_transition(token) + + +def _register_fork_object(instance: _ForkResettable) -> None: + if not _HAS_REGISTER_AT_FORK: + return # pragma: no cover - platform without register_at_fork + with _fork_transition(), _FORK_STATE.registry_lock: + _FORK_OBJECTS[id(instance)] = instance + _refresh_owner_pins() + + +def _register_fork_class(cls: type[_ForkResettableClass]) -> None: + if not _HAS_REGISTER_AT_FORK: + return # pragma: no cover - platform without register_at_fork + with _fork_transition(), _FORK_STATE.registry_lock: + _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: no cover - platform without register_at_fork + with _fork_transition(): + 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: no cover - platform without register_at_fork + with _fork_transition(): + return _record_owned_descriptor(fd, None) + + +def _record_owned_descriptor(fd: int, identity: tuple[int, int] | None) -> int: + 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: + with _fork_transition(), _FORK_STATE.registry_lock: + _OWNED_DESCRIPTORS.pop(token, None) + + +def _pin_fork_objects() -> None: + _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()), # noqa: SLF001 + ) + ) + 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: + 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]: + 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: no cover - exercised in fork children + 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: + 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: + _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: + 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: + if identity is None: + 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: no cover - exercised in fork children + descriptors: list[_OwnedDescriptor], + pinned_objects: tuple[_ForkResettable, ...], + pinned_classes: tuple[type[_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() # noqa: SLF001 + for cls in pinned_classes: + cls._reset_class_after_fork() + _registry.held.clear() + + +def _refresh_owner_pins() -> None: + 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): + 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", @@ -1176,8 +1668,15 @@ class ThreadLocalFileContext(FileLockContext, local): "FileLockMeta", "_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", ] diff --git a/src/filelock/_soft.py b/src/filelock/_soft.py index ca33086d..759dbe1c 100644 --- a/src/filelock/_soft.py +++ b/src/filelock/_soft.py @@ -80,6 +80,7 @@ def _acquire(self) -> None: 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: @@ -91,12 +92,13 @@ def _publish_held_marker(self, fd: int) -> None: 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._context.lock_file_fd = fd + self._mark_descriptor_owned(fd, identity) def _try_break_stale_lock(self) -> None: with suppress(OSError, ValueError): @@ -222,7 +224,7 @@ def _release(self) -> None: 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._context.lock_file_fd = None + 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. diff --git a/src/filelock/_soft_rw/_async.py b/src/filelock/_soft_rw/_async.py index 1dd3c685..2584d99d 100644 --- a/src/filelock/_soft_rw/_async.py +++ b/src/filelock/_soft_rw/_async.py @@ -4,17 +4,20 @@ import asyncio import functools +import os from contextlib import asynccontextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ParamSpec, TypeVar from ._sync import SoftReadWriteLock if TYPE_CHECKING: - import os from collections.abc import AsyncGenerator, Callable from concurrent import futures from types import TracebackType +_P = ParamSpec("_P") +_R = TypeVar("_R") + class AsyncSoftReadWriteLock: """ @@ -51,6 +54,7 @@ def __init__( # noqa: PLR0913 loop: asyncio.AbstractEventLoop | None = None, executor: futures.Executor | None = None, ) -> None: + self._creator_pid = os.getpid() self._lock = SoftReadWriteLock( lock_file, timeout, @@ -143,6 +147,7 @@ async def acquire_read( :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ + self._raise_if_inherited() await self._run(self._lock.acquire_read, timeout, blocking=blocking) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) @@ -165,6 +170,7 @@ async def acquire_write( :raises Timeout: if the lock cannot be acquired within *timeout* seconds """ + self._raise_if_inherited() await self._run(self._lock.acquire_write, timeout, blocking=blocking) return AsyncAcquireSoftReadWriteReturnProxy(lock=self) @@ -177,13 +183,20 @@ async def release(self, *, force: bool = False) -> None: :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ - await self._run(self._lock.release, force=force) + 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.""" - await self._run(self._lock.close) + 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: no cover - exercised only in fork children + msg = f"AsyncSoftReadWriteLock on {self.lock_file} was inherited across fork; construct a new instance" + raise RuntimeError(msg) - async def _run(self, func: Callable[..., object], *args: object, **kwargs: object) -> object: + async def _run(self, func: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs) -> _R: loop = self._loop or asyncio.get_running_loop() return await loop.run_in_executor(self._executor, functools.partial(func, *args, **kwargs)) diff --git a/src/filelock/_soft_rw/_sync.py b/src/filelock/_soft_rw/_sync.py index 086b0d67..1da0cf8d 100644 --- a/src/filelock/_soft_rw/_sync.py +++ b/src/filelock/_soft_rw/_sync.py @@ -19,7 +19,16 @@ from typing import TYPE_CHECKING, Final, Literal from weakref import WeakValueDictionary -from filelock._api import AcquireReturnProxy +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, write_all @@ -42,15 +51,14 @@ # 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[Path, SoftReadWriteLock]] = WeakValueDictionary() -_all_instances_lock = threading.Lock() -_atexit_registered = False -_fork_registered = False +_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.Lock + _instances_lock: threading.RLock def __call__( # noqa: PLR0913 cls, @@ -63,6 +71,7 @@ def __call__( # noqa: PLR0913 stale_threshold: float | None = None, poll_interval: float = 0.25, ) -> SoftReadWriteLock: + _ensure_current_process() if not is_singleton: return super().__call__( lock_file, @@ -78,15 +87,26 @@ def __call__( # noqa: PLR0913 with cls._instances_lock: instance = cls._instances.get(normalized) if instance is None: - instance = super().__call__( - lock_file, - timeout, - blocking=blocking, - is_singleton=is_singleton, - heartbeat_interval=heartbeat_interval, - stale_threshold=stale_threshold, - poll_interval=poll_interval, - ) + if normalized in _SINGLETONS_UNDER_CONSTRUCTION: + 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: + 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 = ( @@ -122,9 +142,8 @@ class SoftReadWriteLock(metaclass=_SoftRWMeta): Reentrancy, upgrade/downgrade rules, thread pinning, and singleton caching by resolved path match :class:`~filelock.ReadWriteLock`. - Forking while holding a lock invalidates the inherited instance in the child so the child cannot - double-own the lock with its parent; ``release()`` on a fork-invalidated instance is a no-op, and - the child must re-acquire if it needs a lock. + 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 @@ -145,7 +164,7 @@ class SoftReadWriteLock(metaclass=_SoftRWMeta): """ _instances: WeakValueDictionary[Path, SoftReadWriteLock] = WeakValueDictionary() - _instances_lock = threading.Lock() + _instances_lock = threading.RLock() def __init__( # noqa: PLR0913 self, @@ -158,6 +177,7 @@ def __init__( # noqa: PLR0913 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) @@ -189,13 +209,21 @@ def __init__( # noqa: PLR0913 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._fork_invalidated: bool = False self._closed: bool = False - with _all_instances_lock: - _all_instances[Path(self.lock_file).resolve()] = self - _register_hooks() + with _ALL_INSTANCES_LOCK: + _ALL_INSTANCES[id(self)] = self + _register_fork_object(self) + + @classmethod + def _reset_class_after_fork(cls) -> None: # pragma: no cover - exercised in fork children + global _ALL_INSTANCES_LOCK # noqa: PLW0603 + _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]: @@ -316,15 +344,21 @@ def close(self) -> None: 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: no cover - exercised only in fork children + return self.release(force=True) with self._locks.internal: if self._closed: return self._closed = True if self._readers_dir_fd is not None: - with suppress(OSError): - os.close(self._readers_dir_fd) - self._readers_dir_fd = None + with _fork_transition(): + if self._readers_dir_fd_token is not None: + _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): + os.close(fd) def release(self, *, force: bool = False) -> None: """ @@ -339,11 +373,9 @@ def release(self, *, force: bool = False) -> None: :raises RuntimeError: if no lock is currently held and *force* is ``False`` """ + if self._creator_pid != os.getpid(): # pragma: no cover - exercised only in fork children + return with self._locks.internal: - if self._fork_invalidated: - # Inherited state from the parent is meaningless in the child; clear any counters and return. - self._hold = None - return hold = self._hold if hold is None: if force: @@ -389,13 +421,13 @@ def _acquire( *, blocking: bool | None, ) -> AcquireReturnProxy: + if self._creator_pid != os.getpid(): # pragma: no cover - exercised only in fork children + 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._fork_invalidated: - msg = f"SoftReadWriteLock on {self.lock_file} was invalidated by fork(); construct a new instance" - raise RuntimeError(msg) if self._closed: msg = f"SoftReadWriteLock on {self.lock_file} has been closed" raise RuntimeError(msg) @@ -491,15 +523,8 @@ def try_claim_writer() -> bool: def readers_drained_touching() -> bool: with self._locks.state: - # Refresh our writer marker every scan iteration so phase 2 does not exceed - # ``stale_threshold`` under contention and get evicted. The refresh only happens while - # the marker is still ours: if we were paused past ``stale_threshold`` a peer can evict - # the stale marker and reclaim ``.write`` with its own token, and touching that path - # blindly would keep the peer's live marker alive and let this acquire finish as though - # we still held the slot, admitting a second writer. When the claim is no longer ours we - # re-claim the slot here (waiting behind the peer if it currently holds it) rather than - # trusting the foreign marker, mirroring the token re-check the stale-break and release - # paths already rely on. + # 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()) @@ -606,9 +631,22 @@ def _open_readers_dir(self) -> None: 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: - self._readers_dir_fd = os.open( - self._paths.readers, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _O_NOFOLLOW - ) + 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: # noqa: BLE001 # 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: for _ in self._iter_reader_entries(): @@ -680,18 +718,15 @@ def _refresh_marker(self) -> bool: finally: os.close(fd) - def _reset_after_fork_in_child(self) -> None: # pragma: no cover - fork child not tracked - # Replace every lock this instance owns with a fresh one; the inherited locks may still be held - # by threads that no longer exist in the child. The readers dirfd and the SoftFileLock state - # mutex both get dropped for the same reason — the child re-creates them on its next acquire. + def _reset_after_fork_in_child(self) -> None: # pragma: no cover - exercised in fork children self._locks = _Locks( internal=threading.Lock(), transaction=threading.Lock(), - state=SoftFileLock(self._paths.state, timeout=-1), + state=self._locks.state, ) self._hold = None self._readers_dir_fd = None - self._fork_invalidated = True + self._readers_dir_fd_token = None class _HeartbeatThread(threading.Thread): @@ -918,31 +953,14 @@ class _Hold: heartbeat_stop: threading.Event -def _register_hooks() -> None: - global _atexit_registered, _fork_registered # noqa: PLW0603 - if not _atexit_registered: - atexit.register(_cleanup_all_instances) - _atexit_registered = True - # after_in_child replaces inherited state so the child cannot double-own any lock the parent held. - if not _fork_registered and hasattr(os, "register_at_fork"): - os.register_at_fork(after_in_child=_reset_all_after_fork) - _fork_registered = True - - def _cleanup_all_instances() -> None: # pragma: no cover - runs from atexit at interpreter shutdown - for instance in list(_all_instances.values()): + for instance in list(_ALL_INSTANCES.values()): with suppress(Exception): instance.release(force=True) -def _reset_all_after_fork() -> None: # pragma: no cover - fork child, not tracked by coverage - global _all_instances_lock # noqa: PLW0603 - # User-created threading locks do not auto-reset across fork: any lock held by a parent thread stays - # locked in the child with no owner to release it. Replace the module-level lock and every instance's - # locks with fresh ones; the child is single-threaded at this point so no synchronization is needed. - _all_instances_lock = threading.Lock() - for instance in list(_all_instances.values()): - instance._reset_after_fork_in_child() # noqa: SLF001 +atexit.register(_cleanup_all_instances) +_register_fork_class(SoftReadWriteLock) __all__ = [ diff --git a/src/filelock/_unix.py b/src/filelock/_unix.py index f970eb35..1053c3e5 100644 --- a/src/filelock/_unix.py +++ b/src/filelock/_unix.py @@ -67,6 +67,11 @@ class UnixFileLock(BaseFileLock): """ 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 @@ -83,7 +88,7 @@ def _acquire(self) -> None: # 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 + return None raise except PermissionError: # Sticky-bit dirs (e.g. /tmp): O_CREAT fails if the file is owned by another user (#317). @@ -93,19 +98,22 @@ def _acquire(self) -> None: try: fd = os.open(self.lock_file, open_flags & ~os.O_CREAT, open_mode) except FileNotFoundError: - return + 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 - self._switch_to_soft_lock(fd, exception) - return + 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 @@ -113,6 +121,7 @@ def _switch_to_soft_lock(self, fd: int, missing_flock: OSError) -> None: 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 @@ -131,17 +140,19 @@ def _finalize_locked_fd(self, fd: int) -> None: # flock() (st_nlink 0), leaving a useless dead-inode lock; drop it and let the retry loop start fresh. keep = False try: - if os.fstat(fd).st_nlink != 0: + 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._context.lock_file_fd = fd # the lock is held; run the hook now that truncation and mode are set - self._invoke_on_acquired() + 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: @@ -163,7 +174,7 @@ def _release(self) -> None: # 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._context.lock_file_fd = None + self._mark_descriptor_released() self._close_released_fd(fd, default_suppresses=True) diff --git a/src/filelock/_windows.py b/src/filelock/_windows.py index b26d8ba3..83dc51ae 100644 --- a/src/filelock/_windows.py +++ b/src/filelock/_windows.py @@ -185,13 +185,12 @@ def _acquire(self) -> 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: os.close(fd) raise - if locked: - self._context.lock_file_fd = fd - self._invoke_on_acquired() - else: + if not locked: os.close(fd) # another holder owns the byte-range lock; let the retry loop try again def _release(self) -> None: @@ -200,7 +199,7 @@ def _release(self) -> None: # 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._context.lock_file_fd = None + 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): diff --git a/src/filelock/asyncio.py b/src/filelock/asyncio.py index a7570b74..d07c8a65 100644 --- a/src/filelock/asyncio.py +++ b/src/filelock/asyncio.py @@ -21,10 +21,13 @@ FileLockMeta, _append_exception_context, _canonical, + _fork_transition, _grouped_errors, _raise_body_and_release, _raise_chained_errors, + _raise_cleanup_errors, _raise_grouped_errors, + _register_fork_object, ) from ._async import ( _AsyncTransitionGate, @@ -43,7 +46,7 @@ if TYPE_CHECKING: import sys - from collections.abc import Callable, Coroutine + from collections.abc import Awaitable, Callable, Coroutine from concurrent import futures from types import TracebackType @@ -186,6 +189,7 @@ def __init__( # noqa: PLR0913 :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 @@ -206,6 +210,7 @@ def __init__( # noqa: PLR0913 run_in_executor=run_in_executor, executor=executor, ) + _register_fork_object(self) @property def run_in_executor(self) -> bool: @@ -271,6 +276,7 @@ async def acquire( # ty: ignore[invalid-method-override] lock.release() """ + self._raise_if_inherited() if timeout is None: timeout = self._context.timeout @@ -337,10 +343,12 @@ async def _async_poll_until_acquired( 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 @@ -357,7 +365,11 @@ async def _async_poll_until_acquired( await asyncio.sleep(poll_interval) async def _run_acquire_attempt(self) -> None: - acquire_future = self._start_internal_method(self._acquire) + 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: @@ -370,7 +382,7 @@ async def _run_acquire_attempt(self) -> None: rollback_error: BaseException | None = None if self.is_locked: try: - await _drain_future(self._start_internal_method(self._release)) + await _drain_future(self._start_tracked_release()) except BaseException as error: # noqa: BLE001 # reported with the cancellation below rollback_error = error @@ -398,7 +410,7 @@ async def _run_acquire_attempt(self) -> None: async def _rollback_backend_cancelled_acquire(self, acquire_error: asyncio.CancelledError) -> NoReturn: if self.is_locked: try: - await _drain_future(self._start_internal_method(self._release)) + await _drain_future(self._start_tracked_release()) except BaseException as rollback_error: # noqa: BLE001 # both backend errors must surface self._raise_acquire_rollback_errors(acquire_error, rollback_error) raise acquire_error @@ -439,7 +451,7 @@ async def release(self, force: bool = False) -> None: # ty: ignore[invalid-meth :param force: If true, the lock counter is ignored and the lock is released in every case. """ - if not self.is_locked: + if self._creator_pid != os.getpid() or not self.is_locked: return async with self._transition_gate.hold(): await self._release_serialized(force=force) @@ -453,7 +465,7 @@ async def _release_serialized(self, *, force: bool) -> None: 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_internal_method(self._release) + release_future = self._start_tracked_release() try: await _wait_until_done(release_future) except asyncio.CancelledError as cancellation: @@ -491,6 +503,96 @@ def _start_internal_method( 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: # noqa: BLE001 # 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: # noqa: BLE001 # pragma: no cover - allocation/control-flow during fallback + tracking_error = error + try: + await _drain_future(self._start_tracked_release()) + except BaseException as rollback_error: # noqa: BLE001 # 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: # noqa: BLE001 # pragma: no cover - allocation/control-flow during fallback + tracking_error = error + try: + await _drain_future(self._start_tracked_release()) + except BaseException as rollback_error: # noqa: BLE001 # 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: # noqa: BLE001 # 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." @@ -558,6 +660,8 @@ async def _release_in_context( # ty: ignore[invalid-method-override] 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: no cover - exercised in fork children with contextlib.suppress(Exception): try: loop = asyncio.get_running_loop() diff --git a/tests/fork_helpers.py b/tests/fork_helpers.py new file mode 100644 index 00000000..fcbe7367 --- /dev/null +++ b/tests/fork_helpers.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Final, NoReturn, cast + +from coverage import Coverage, process_startup + +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: + 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: + child() # pragma: no cover - child coverage starts after fork returns to this frame + return child_pid + + +def exit_child(status: int) -> NoReturn: + try: + if (coverage := Coverage.current()) is not None: + coverage.save() + finally: + # A coverage write failure must not return the fork child to pytest. + os._exit(status) + + +def _restart_coverage_after_fork() -> None: + if (coverage := Coverage.current()) is not None: + 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: + _REGISTER_AT_FORK(after_in_child=_restart_coverage_after_fork) + + +__all__ = ["exit_child", "fork_process"] diff --git a/tests/test_fork_backends.py b/tests/test_fork_backends.py new file mode 100644 index 00000000..8921a235 --- /dev/null +++ b/tests/test_fork_backends.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import asyncio +import os +import subprocess # noqa: S404 # isolated interpreter controls child callback registration order +import sys +from errno import EBADF +from typing import TYPE_CHECKING, Final, NoReturn, cast + +import pytest +from fork_helpers import exit_child, fork_process + +from filelock import BaseAsyncFileLock, BaseFileLock + +if sys.version_info >= (3, 11): + from builtins import BaseExceptionGroup +else: # pragma: no cover ( None: + 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: + 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: + 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: + 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 + + +@_REQUIRES_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 + + +@_REQUIRES_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 + + +@_REQUIRES_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", + ), + ], +) +@_REQUIRES_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) + + +@_REQUIRES_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, + ) + + +@_REQUIRES_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 +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, "") + + +@_REQUIRES_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 +@_REQUIRES_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) + + +@_REQUIRES_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", + ), + ], +) +@_REQUIRES_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) + + 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) + + with pytest.raises(OSError, match=rf"\[Errno {EBADF}\]"): + real_fstat(descriptor) + assert (_error_details(info.value), os.waitstatus_to_exitcode(status)) == (expected, 0) + + +@_REQUIRES_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, + ) + + +@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(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) diff --git a/tests/test_fork_coordination.py b/tests/test_fork_coordination.py new file mode 100644 index 00000000..718e8222 --- /dev/null +++ b/tests/test_fork_coordination.py @@ -0,0 +1,1103 @@ +from __future__ import annotations + +import os +import subprocess # noqa: S404 # isolated interpreters control at-fork registration order +import sys +import threading +from typing import TYPE_CHECKING, Final, Literal + +import pytest +from fork_helpers import exit_child, fork_process + +from filelock import BaseFileLock, Timeout, has_fcntl + +if TYPE_CHECKING: + from pathlib import Path + +_REQUIRES_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif(not hasattr(os, "fork"), reason="os.fork required") +_FORK_WARNING: Final[pytest.MarkDecorator] = pytest.mark.filterwarnings( + "ignore:.*multi-threaded, use of fork.*:DeprecationWarning" +) + + +@_REQUIRES_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, "") + + +@_REQUIRES_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 + + +@_REQUIRES_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) + + +@_REQUIRES_FORK +@_FORK_WARNING +@pytest.mark.parametrize( + "fork_mode", + [ + pytest.param("rejected-audit", id="rejected-audit-hook"), + pytest.param( + "fork1", + marks=pytest.mark.skipif(not hasattr(os, "fork1"), reason="os.fork1 required"), + 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, "") + + +@_REQUIRES_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=pytest.mark.skipif(not hasattr(os, "fork1"), reason="os.fork1 required"), + 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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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) + + 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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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, "") + + +@_REQUIRES_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) + + +@_REQUIRES_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: + 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) diff --git a/tests/test_fork_ownership.py b/tests/test_fork_ownership.py new file mode 100644 index 00000000..65bf987f --- /dev/null +++ b/tests/test_fork_ownership.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +import asyncio +import gc +import os +import subprocess # noqa: S404 # 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 fork_helpers import exit_child, fork_process + +from filelock import ( + AcquireReturnProxy, + AsyncFileLock, + AsyncSoftFileLock, + BaseAsyncFileLock, + BaseFileLock, + FileLock, + SoftFileLock, + SoftReadWriteLock, + Timeout, +) + +if sys.version_info >= (3, 11): + from builtins import BaseExceptionGroup +else: # pragma: no cover ( 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) + + +@_REQUIRES_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) + + +@_REQUIRES_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() + + +@_REQUIRES_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) + + +@_REQUIRES_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() + + +@pytest.mark.skipif(not hasattr(os, "register_at_fork"), reason="fork transition gate requires register_at_fork") +def test_unrelated_acquisitions_reach_filesystem_boundary_concurrently(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: + if threading.current_thread().name.startswith("concurrent-acquire-"): + 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] + + +@_REQUIRES_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 + + +@_REQUIRES_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 + + +@_REQUIRES_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 + + +@_REQUIRES_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() + + +@pytest.mark.skipif(not hasattr(os, "register_at_fork"), reason="descriptor registry requires register_at_fork") +def test_reader_directory_registration_failure_closes_descriptor(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() + + +@pytest.mark.skipif(not hasattr(os, "register_at_fork"), reason="descriptor registry requires register_at_fork") +def test_reader_directory_registration_and_close_errors_are_grouped(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"), + ] + + +@_REQUIRES_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, "") + + +@_REQUIRES_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: + 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) diff --git a/tests/test_fork_registries.py b/tests/test_fork_registries.py new file mode 100644 index 00000000..a6d463ca --- /dev/null +++ b/tests/test_fork_registries.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import gc +import os +import subprocess # noqa: S404 # interpreter exit exercises the atexit registry +import sys +import weakref +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, NoReturn + +import pytest +from fork_helpers import exit_child, fork_process + +from filelock import BaseFileLock + +if TYPE_CHECKING: + from pathlib import Path + +_REQUIRES_FORK: Final[pytest.MarkDecorator] = pytest.mark.skipif(not hasattr(os, "fork"), reason="os.fork required") + + +@_REQUIRES_FORK +def test_equal_unhashable_locks_reset_independently(tmp_path: Path) -> None: + @dataclass(eq=True, init=False) + class EqualLock(BaseFileLock): + 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 + + 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]) + + +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 diff --git a/tests/test_unix_fallback.py b/tests/test_unix_fallback.py index 4ce158b9..8d0ba391 100644 --- a/tests/test_unix_fallback.py +++ b/tests/test_unix_fallback.py @@ -155,6 +155,24 @@ def _close_eio(fd: int) -> None: assert not lock.is_locked +@_UNIX_ONLY +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: return mocker.patch("filelock._unix.fcntl.flock", side_effect=OSError(ENOSYS, "Function not implemented"))