diff --git a/tasks/coverage_pragmas.py b/tasks/coverage_pragmas.py index cbe5ae77..92800bae 100644 --- a/tasks/coverage_pragmas.py +++ b/tasks/coverage_pragmas.py @@ -14,23 +14,38 @@ from __future__ import annotations +import gc import os import signal import socket +import sys import tempfile +import weakref +from asyncio import CancelledError +from contextlib import asynccontextmanager, contextmanager from importlib.util import find_spec from pathlib import Path -from typing import TYPE_CHECKING, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast from coverage import CoveragePlugin if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import AsyncIterator, Generator, Iterable, Iterator from coverage.plugin_support import Plugins from coverage.types import TConfigurable +class _Suspend: + """An awaitable that parks its coroutine once, so a probe can throw into a suspended frame without a loop.""" + + def __await__(self) -> Generator[None, Any, None]: + yield + + +_AUDIT_PROBE_EVENT: Final[str] = "filelock.capability-probe" + + # coverage passes the config's plugin options; this plugin takes none. def coverage_init(reg: Plugins, options: dict[str, str]) -> None: # ruff:ignore[unused-function-argument] reg.add_configurer(CapabilityPragmas()) @@ -102,6 +117,111 @@ def _honors_link_follow_symlinks() -> bool: return True +def _finalizes_on_last_reference() -> bool: + # Only a refcounting collector runs __del__ the moment the last reference goes; a tracing one defers it. + finalized: list[bool] = [] + + class Probe: + def __del__(self) -> None: + finalized.append(True) + + Probe() + return bool(finalized) + + +def _finalizes_on_collection() -> bool: + # GraalPy queues finalizers on the host collector, so even a forced collection does not run __del__. + finalized: list[bool] = [] + + class Probe: + def __del__(self) -> None: + finalized.append(True) + + Probe() + gc.collect() + return bool(finalized) + + +def _collects_classes() -> bool: + # A dynamically built lock subclass must not outlive its last reference, or the registries keyed on it leak. + def build() -> type: + class Probe: + pass + + return Probe + + reference = weakref.ref(build()) + gc.collect() + return reference() is None + + +def _preserves_context_thrown_into_a_generator() -> bool: + # GraalPy resets __context__ when contextlib throws into the suspended generator, losing the chained cause. + @contextmanager + def probe() -> Iterator[None]: + yield + + error = KeyError("thrown") + error.__context__ = ValueError("context") + try: + with probe(): + raise error + except KeyError as caught: + return caught.__context__ is not None + return False # pragma: no cover # the raise above always propagates + + +def _propagates_a_cancellation_thrown_into_a_coroutine() -> bool: + # Driven by hand so the probe needs no event loop: GraalPy answers athrow with RuntimeError instead of the + # CancelledError, so every cancellation crossing an async context manager surfaces as the wrong exception. + @asynccontextmanager + async def gate() -> AsyncIterator[None]: + await _Suspend() + yield + + async def body() -> None: + async with gate(): + pass + + coroutine = body() + coroutine.send(None) + try: + coroutine.throw(CancelledError()) + except CancelledError: + return True + except BaseException: # ruff:ignore[blind-except] # whatever else a runtime substitutes counts as the deviation + return False + return False # pragma: no cover # throwing into the suspended coroutine always raises + + +def _delivers_audit_events() -> bool: + # GraalPy accepts a hook and never calls it. The hook is a permanent no-op; tests install their own anyway. + delivered: list[bool] = [] + sys.addaudithook(lambda event, _args: delivered.append(True) if event == _AUDIT_PROBE_EVENT else None) + sys.audit(_AUDIT_PROBE_EVENT) + return bool(delivered) + + +def _refuses_to_open_a_symlink() -> bool: + # GraalPy accepts O_NOFOLLOW then follows the link anyway, so ask for the refusal rather than the constant. + if not hasattr(os, "O_NOFOLLOW"): + return False + if not _supports_symlink(): + # Nothing to point the probe at, so keep the constant's answer rather than reporting a gap we cannot see. + return True + with tempfile.TemporaryDirectory() as directory: + target = Path(directory, "target") + target.touch() + link = Path(directory, "link") + link.symlink_to(target) + try: + descriptor = os.open(link, os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + return True + os.close(descriptor) + return False + + def _enforces_file_mode() -> bool: # Without POSIX permission bits a chmod does not read back. with tempfile.TemporaryDirectory() as directory: @@ -117,17 +237,25 @@ def _enforces_file_mode() -> bool: CAPABILITIES: Final[dict[str, bool]] = { "fork": hasattr(os, "fork") and hasattr(os, "register_at_fork"), "dir-fd": os.open in os.supports_dir_fd, + # Narrower than "dir-fd": GraalPy takes os.open relative to a directory descriptor but not os.link. + "link-dir-fd": hasattr(os, "link") and os.link in os.supports_dir_fd, "hard-link": hasattr(os, "link"), "symlink": _supports_symlink(), "fcntl": find_spec("fcntl") is not None, "unlink-open-file": _supports_unlinking_an_open_file(), "posix-signals": hasattr(signal, "SIGKILL"), "file-mode": _enforces_file_mode(), + "prompt-finalization": _finalizes_on_last_reference(), + "collected-finalization": _finalizes_on_collection(), + "class-collection": _collects_classes(), + "generator-exception-context": _preserves_context_thrown_into_a_generator(), + "coroutine-cancellation": _propagates_a_cancellation_thrown_into_a_coroutine(), + "audit-events": _delivers_audit_events(), "fd-directory": any(Path(view).is_dir() for view in ("/dev/fd", "/proc/self/fd")), "fifo": hasattr(os, "mkfifo"), "af-unix": hasattr(socket, "AF_UNIX"), # Distinct from "symlink": a runtime can create them yet still not refuse to follow one. - "o-nofollow": hasattr(os, "O_NOFOLLOW"), + "o-nofollow": _refuses_to_open_a_symlink(), "utime-nofollow": os.utime in os.supports_follow_symlinks, "utime-fd": os.utime in os.supports_fd, "sqlite3": find_spec("sqlite3") is not None, diff --git a/tests/capability_marks.py b/tests/capability_marks.py new file mode 100644 index 00000000..5b658faa --- /dev/null +++ b/tests/capability_marks.py @@ -0,0 +1,59 @@ +"""Marks for runtime capabilities several test modules gate on. + +Capabilities used by a single module stay private to it, as ``_NEEDS_SYMLINK`` and friends do. These span modules, so +they live here and read the same ``CAPABILITIES`` probes the coverage pragmas use. +""" + +from __future__ import annotations + +import pytest +from coverage_pragmas import CAPABILITIES + +#: A dropped reference releases what its __del__ releases. Only a refcounting collector does this. +NEEDS_PROMPT_FINALIZATION = pytest.mark.skipif( + not CAPABILITIES["prompt-finalization"], + reason="a dropped reference does not run __del__ on a deferred collector", +) + +#: gc.collect() runs pending __del__ methods. GraalPy hands them to the host collector and never gets them back. +NEEDS_COLLECTED_FINALIZATION = pytest.mark.skipif( + not CAPABILITIES["collected-finalization"], + reason="gc.collect() does not run __del__ on this runtime", +) + +#: gc.collect() reclaims a dynamically built class once its last reference goes. +NEEDS_CLASS_COLLECTION = pytest.mark.skipif( + not CAPABILITIES["class-collection"], + reason="gc.collect() does not reclaim classes on this runtime", +) + +#: An exception thrown into a suspended generator or coroutine keeps its __context__. +NEEDS_GENERATOR_EXCEPTION_CONTEXT = pytest.mark.skipif( + not CAPABILITIES["generator-exception-context"], + reason="this runtime clears __context__ when an exception is thrown into a suspended frame", +) + +#: sys.audit delivers to hooks installed with sys.addaudithook. GraalPy accepts the hook and never calls it. +NEEDS_AUDIT_EVENTS = pytest.mark.skipif( + not CAPABILITIES["audit-events"], + reason="this runtime never delivers audit events to an installed hook", +) + +#: A cancellation crossing an async context manager surfaces as CancelledError rather than the interpreter's own +#: bookkeeping error. GraalPy's contextlib raises ``RuntimeError: generator didn't stop after athrow()`` from +#: ``_GeneratorContextManagerBase.__aexit__`` instead, so the lock's cancellation contract cannot be observed there. +#: Not strict: the deviation depends on where the cancellation lands, so some of these tests still pass. +XFAIL_WITHOUT_COROUTINE_CANCELLATION = pytest.mark.xfail( + not CAPABILITIES["coroutine-cancellation"], + reason="GraalPy's contextlib answers athrow() with RuntimeError instead of propagating the CancelledError", + strict=False, +) + +__all__ = [ + "NEEDS_AUDIT_EVENTS", + "NEEDS_CLASS_COLLECTION", + "NEEDS_COLLECTED_FINALIZATION", + "NEEDS_GENERATOR_EXCEPTION_CONTEXT", + "NEEDS_PROMPT_FINALIZATION", + "XFAIL_WITHOUT_COROUTINE_CANCELLATION", +] diff --git a/tests/soft_rw/test_soft_rw_sync.py b/tests/soft_rw/test_soft_rw_sync.py index 83c739fe..611c0174 100644 --- a/tests/soft_rw/test_soft_rw_sync.py +++ b/tests/soft_rw/test_soft_rw_sync.py @@ -731,8 +731,8 @@ def test_stale_malformed_marker_is_evicted(lock_file: str, content: bytes) -> No def test_fifo_write_marker_does_not_block(lock_file: str) -> None: # pragma: needs fifo - if sys.platform == "win32": # pragma: win32 cover - pytest.skip("os.mkfifo is unix-only") # also narrows sys.platform so ty resolves os.mkfifo below + if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover + pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below marker = f"{lock_file}.write" os.mkfifo(marker) past = time.time() - 1000 @@ -747,8 +747,8 @@ def test_fifo_write_marker_does_not_block(lock_file: str) -> None: # pragma: ne def test_fifo_write_marker_with_writer_is_evicted(lock_file: str) -> None: # pragma: needs fifo - if sys.platform == "win32": # pragma: win32 cover - pytest.skip("os.mkfifo is unix-only") # also narrows sys.platform so ty resolves os.mkfifo below + if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover + pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below marker = f"{lock_file}.write" os.mkfifo(marker) past = time.time() - 1000 @@ -797,7 +797,7 @@ def test_touch_does_not_follow_symlink(lock_file: str, tmp_path: Path) -> None: util_mod.touch(str(marker)) - assert victim.stat().st_mtime == past + assert victim.stat().st_mtime == pytest.approx(past) # a timestamp round-trip need not be bit-exact assert victim.read_text() == "do-not-touch" @@ -827,7 +827,7 @@ def swap_after_open(name: str, *, dir_fd: int | None = None) -> int | None: monkeypatch.setattr(sync_mod, "_open_marker", swap_after_open) assert lock._refresh_marker() is True - assert victim.stat().st_mtime == past + assert victim.stat().st_mtime == pytest.approx(past) # a timestamp round-trip need not be bit-exact assert victim.read_text() == "do-not-touch" finally: lock.release(force=True) diff --git a/tests/test_async_filelock_acquire_cancellation.py b/tests/test_async_filelock_acquire_cancellation.py index 7940d836..7b8e4099 100644 --- a/tests/test_async_filelock_acquire_cancellation.py +++ b/tests/test_async_filelock_acquire_cancellation.py @@ -11,6 +11,7 @@ import pytest from async_filelock_cancellation_helpers import assert_cancellation_message, assert_file_lock_state +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock import ( AsyncFileLock, @@ -56,6 +57,7 @@ async def _release(self) -> None: # ty: ignore[invalid-method-override] @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_backend_cancellation_rollback_failure_follows_policy(tmp_path: Path, policy: ContextErrorPolicy) -> None: backend_cancellation = asyncio.CancelledError("backend canceled") rollback_error = OSError(EIO, "rollback failed") @@ -185,6 +187,7 @@ def block_hook(_fd: int) -> None: @_NEEDS_FCNTL @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancelled_queued_acquire_does_not_claim_transition(tmp_path: Path) -> None: # pragma: needs fcntl hook_started = asyncio.Event() finish_hook = threading.Event() @@ -251,6 +254,7 @@ def block_rollback(_fd: int, _operation: int) -> None: @_NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_error_after_rollback( # pragma: needs fcntl tmp_path: Path, policy: ContextErrorPolicy ) -> None: @@ -299,6 +303,7 @@ def fail_hook(_fd: int) -> None: [pytest.param("unrelated", True, id="distinct"), pytest.param("attempt", False, id="equivalent")], ) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_group_reconciles_attempt_context( # pragma: needs fcntl tmp_path: Path, context_message: str, *, preserved: bool ) -> None: @@ -347,6 +352,7 @@ def fail_hook(_fd: int) -> None: @_NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_rollback_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, @@ -395,6 +401,7 @@ def block_hook(_fd: int) -> None: @_NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_attempt_and_rollback_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: diff --git a/tests/test_async_filelock_release_cancellation.py b/tests/test_async_filelock_release_cancellation.py index 29da87fd..fc25c209 100644 --- a/tests/test_async_filelock_release_cancellation.py +++ b/tests/test_async_filelock_release_cancellation.py @@ -15,6 +15,7 @@ get_fcntl, start_file_lock_holder, ) +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock import AsyncFileLock, BaseAsyncFileLock, ContextErrorPolicy @@ -59,6 +60,7 @@ def block_unlock(_fd: int, _operation: int) -> None: @_NEEDS_FCNTL @pytest.mark.asyncio # pragma: needs fcntl +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_proceeds_after_queued_release_is_canceled(tmp_path: Path, mocker: MockerFixture) -> None: lock = AsyncFileLock(tmp_path / "a") await lock.acquire() @@ -158,6 +160,7 @@ def observe_first_poll() -> bool: @_NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, policy: ContextErrorPolicy ) -> None: diff --git a/tests/test_async_filelock_rollback_agnostic.py b/tests/test_async_filelock_rollback_agnostic.py index aaadeb18..63d375e4 100644 --- a/tests/test_async_filelock_rollback_agnostic.py +++ b/tests/test_async_filelock_rollback_agnostic.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING import pytest +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock import AsyncFileLock, BaseAsyncFileLock @@ -50,6 +51,7 @@ async def _release(self) -> None: # ty: ignore[invalid-method-override] @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_release_cancellation_surfaces_backend_error(tmp_path: Path) -> None: backend_error = OSError(EIO, "backend release failed") release_started = asyncio.Event() @@ -86,6 +88,7 @@ async def _release(self) -> None: # ty: ignore[invalid-method-override] @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_rollback_failure_surfaces_backend_error(tmp_path: Path) -> None: rollback_error = OSError(EIO, "rollback release failed") acquire_started = asyncio.Event() diff --git a/tests/test_async_filelock_runner_shutdown.py b/tests/test_async_filelock_runner_shutdown.py index 4e06b44f..ed37b24d 100644 --- a/tests/test_async_filelock_runner_shutdown.py +++ b/tests/test_async_filelock_runner_shutdown.py @@ -10,6 +10,7 @@ import pytest from async_filelock_cancellation_helpers import assert_file_lock_state, get_fcntl +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock import AsyncAcquireReturnProxy, AsyncFileLock, ContextErrorPolicy @@ -78,6 +79,7 @@ def block_hook(_fd: int) -> None: @_NEEDS_FCNTL @pytest.mark.parametrize("policy", [pytest.param("chain", id="chain"), pytest.param("group", id="group")]) +@XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_runner_shutdown_preserves_body_cancellation_and_release_errors( # pragma: needs fcntl tmp_path: Path, mocker: MockerFixture, policy: ContextErrorPolicy ) -> None: diff --git a/tests/test_async_filelock_transition_admission.py b/tests/test_async_filelock_transition_admission.py index 97602e62..4a0eb922 100644 --- a/tests/test_async_filelock_transition_admission.py +++ b/tests/test_async_filelock_transition_admission.py @@ -11,6 +11,7 @@ assert_file_lock_state, start_file_lock_holder, ) +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock import AsyncFileLock, Timeout @@ -32,6 +33,7 @@ ], ) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_honors_own_admission_policy( tmp_path: Path, admission: Literal["nonblocking", "deadline", "cancel-check"] ) -> None: @@ -80,6 +82,7 @@ async def acquire_second() -> None: @_UNIX_FLOCK_ONLY @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_queued_acquire_proceeds_after_prior_waiter_cancels(tmp_path: Path) -> None: # pragma: win32 no cover hook_started = asyncio.Event() finish_hook = threading.Event() diff --git a/tests/test_async_read_write.py b/tests/test_async_read_write.py index 12b5ff70..a59b3c6d 100644 --- a/tests/test_async_read_write.py +++ b/tests/test_async_read_write.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal import pytest +from capability_marks import NEEDS_COLLECTED_FINALIZATION pytest.importorskip("sqlite3") @@ -201,6 +202,7 @@ async def test_close_keeps_provided_executor_open(lock_file: str) -> None: executor.shutdown(wait=False) +@NEEDS_COLLECTED_FINALIZATION def test_del_shuts_down_owned_executor(lock_file: str) -> None: lock = AsyncReadWriteLock(lock_file, is_singleton=False) executor = lock.executor diff --git a/tests/test_async_read_write_cancellation.py b/tests/test_async_read_write_cancellation.py index 723e1c5f..9b746106 100644 --- a/tests/test_async_read_write_cancellation.py +++ b/tests/test_async_read_write_cancellation.py @@ -11,6 +11,7 @@ import pytest from async_filelock_cancellation_helpers import assert_cancellation_message +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from read_write_helpers import assert_read_write_lock_state from filelock import AsyncReadWriteLock, ReadWriteLock @@ -68,6 +69,7 @@ async def test_acquire_cancellation_before_executor_start_rolls_back( "cancel_caller", [pytest.param(False, id="executor"), pytest.param(True, id="caller-and-executor")], ) +@XFAIL_WITHOUT_COROUTINE_CANCELLATION def test_executor_cancels_queued_acquire_without_compensation(lock_file: str, *, cancel_caller: bool) -> None: context = multiprocessing.get_context("spawn") canceled = context.Value("b", False) @@ -82,6 +84,7 @@ def test_executor_cancels_queued_acquire_without_compensation(lock_file: str, *, @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_caller_cancellation_preserves_cancelled_executor_acquire(lock_file: str) -> None: executor_started = threading.Event() release_executor = threading.Event() @@ -115,6 +118,7 @@ async def test_caller_cancellation_preserves_cancelled_executor_acquire(lock_fil @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_compensation_failure(lock_file: str, mocker: MockerFixture) -> None: executor_started = threading.Event() release_executor = threading.Event() @@ -188,6 +192,7 @@ async def test_acquire_cancellation_while_sqlite_waits_rolls_back( @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_acquire_cancellation_surfaces_acquire_and_rollback_errors( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: @@ -245,6 +250,7 @@ async def test_close_cancellation_shuts_down_owned_executor(lock_file: str, mock @pytest.mark.parametrize("operation", [pytest.param("release", id="release"), pytest.param("close", id="close")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_cancellation_surfaces_rollback_error( lock_file: str, mocker: MockerFixture, operation: Literal["release", "close"] ) -> None: @@ -280,6 +286,7 @@ async def test_cancellation_surfaces_rollback_error( @pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")]) @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_context_cancellation_preserves_body_and_rollback_contexts( lock_file: str, mocker: MockerFixture, mode: Literal["read", "write"] ) -> None: diff --git a/tests/test_async_transition_gate.py b/tests/test_async_transition_gate.py index 60410cd9..d593750f 100644 --- a/tests/test_async_transition_gate.py +++ b/tests/test_async_transition_gate.py @@ -4,6 +4,7 @@ from concurrent.futures import Future as ConcurrentFuture import pytest +from capability_marks import XFAIL_WITHOUT_COROUTINE_CANCELLATION from filelock._async import _AsyncTransitionGate @@ -38,6 +39,7 @@ async def second() -> None: @pytest.mark.asyncio +@XFAIL_WITHOUT_COROUTINE_CANCELLATION async def test_hold_canceled_while_waiting_lets_the_predecessor_free_the_ticket() -> None: gate = _AsyncTransitionGate() first_holding = asyncio.Event() diff --git a/tests/test_filelock.py b/tests/test_filelock.py index a0ef07e0..25863638 100644 --- a/tests/test_filelock.py +++ b/tests/test_filelock.py @@ -21,6 +21,7 @@ from weakref import WeakValueDictionary import pytest +from capability_marks import NEEDS_PROMPT_FINALIZATION from coverage_pragmas import CAPABILITIES from filelock import ( @@ -480,7 +481,7 @@ def test_acquire_release_on_exc(lock_type: type[BaseFileLock], tmp_path: Path) - assert not lock.is_locked -@pytest.mark.skipif(hasattr(sys, "pypy_version_info"), reason="del() does not trigger GC in PyPy") +@NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_del(lock_type: type[BaseFileLock], tmp_path: Path) -> None: lock_path = tmp_path / "a" @@ -900,7 +901,7 @@ def test_singleton_locks_must_be_initialized_with_the_same_args(lock_type: type[ del lock, exc_info -@pytest.mark.skipif(hasattr(sys, "pypy_version_info"), reason="del() does not trigger GC in PyPy") +@NEEDS_PROMPT_FINALIZATION @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_locks_are_deleted_when_no_external_references_exist( lock_type: type[BaseFileLock], @@ -914,7 +915,6 @@ def test_singleton_locks_are_deleted_when_no_external_references_exist( assert lock_type._instances == {} -@pytest.mark.skipif(hasattr(sys, "pypy_version_info"), reason="del() does not trigger GC in PyPy") @pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock]) def test_singleton_instance_tracking_is_unique_per_subclass(lock_type: type[BaseFileLock]) -> None: class Lock1(lock_type): # ty: ignore[unsupported-base] @@ -1343,6 +1343,10 @@ def acquire_in_thread() -> None: not CAPABILITIES["symlink"], reason="creating a symlink needs Developer Mode or SeCreateSymbolicLinkPrivilege" ) +_NEEDS_O_NOFOLLOW: Final[pytest.MarkDecorator] = pytest.mark.skipif( + not CAPABILITIES["o-nofollow"], reason="refusing to open through a final symlink needs a honored O_NOFOLLOW" +) + # Windows resolves a lock's parent with abspath, so a symlinked parent stays a distinct key and never collapses. _NEEDS_PARENT_SYMLINK_COLLAPSE: Final[pytest.MarkDecorator] = pytest.mark.skipif( not CAPABILITIES["symlink"] or sys.platform == "win32", @@ -1994,6 +1998,7 @@ def test_final_symlink_stays_a_distinct_key(tmp_path: Path) -> None: @_NEEDS_FCNTL # pragma: needs fcntl +@_NEEDS_O_NOFOLLOW # pragma: needs o-nofollow def test_final_symlink_backend_refuses_to_lock(tmp_path: Path) -> None: (tmp_path / "target").write_text("") (tmp_path / "link").symlink_to(tmp_path / "target") diff --git a/tests/test_fork_backends.py b/tests/test_fork_backends.py index f72317e0..ce2fdd05 100644 --- a/tests/test_fork_backends.py +++ b/tests/test_fork_backends.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Final, NoReturn, cast import pytest +from capability_marks import NEEDS_GENERATOR_EXCEPTION_CONTEXT from fork_helpers import exit_child, fork_process from filelock import BaseAsyncFileLock, BaseFileLock @@ -457,6 +458,7 @@ def fail_registration(fd: int) -> os.stat_result: ) +@NEEDS_GENERATOR_EXCEPTION_CONTEXT @pytest.mark.asyncio async def test_coroutine_on_acquired_error_preserves_context(tmp_path: Path) -> None: callback_error = RuntimeError("hook failed") diff --git a/tests/test_fork_registries.py b/tests/test_fork_registries.py index 8f596602..27865dfb 100644 --- a/tests/test_fork_registries.py +++ b/tests/test_fork_registries.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Final, NoReturn import pytest +from capability_marks import NEEDS_CLASS_COLLECTION from fork_helpers import exit_child, fork_process from filelock import BaseFileLock @@ -76,6 +77,7 @@ class EqualLock(SoftReadWriteLock): assert (result.returncode, [path.with_name(f"{path.name}.write").exists() for path in paths]) == (0, [False, False]) +@NEEDS_CLASS_COLLECTION def test_dynamic_lock_class_can_be_collected() -> None: class EphemeralLock(BaseFileLock): _acquire = _release = BaseFileLock._acquire diff --git a/tests/test_read_write.py b/tests/test_read_write.py index 9b07ec66..d9b5f327 100644 --- a/tests/test_read_write.py +++ b/tests/test_read_write.py @@ -138,6 +138,7 @@ def test_write_non_starvation(lock_file: str) -> None: chain_forward = [Event() for _ in range(NUM_READERS)] chain_backward = [Event() for _ in range(NUM_READERS)] writer_ready = Event() + writer_contending = Event() writer_acquired = Event() release_count = Value("i", 0) @@ -152,7 +153,10 @@ def test_write_non_starvation(lock_file: str) -> None: ) readers.append(reader) - writer = Process(target=acquire_lock, args=(lock_file, "write", writer_acquired, None, 20, True, writer_ready)) + writer = Process( + target=acquire_lock, + args=(lock_file, "write", writer_acquired, None, 20, True, writer_ready, writer_contending), + ) with cleanup_processes([*readers, writer]): for reader in readers: @@ -164,10 +168,15 @@ def test_write_non_starvation(lock_file: str) -> None: writer.start() + # Count only the releases the writer actually waited through; a slow interpreter start is not starvation. + assert writer_contending.wait(timeout=20), "Writer process did not reach the lock" + with release_count.get_lock(): + releases_before_contending = release_count.value + assert writer_acquired.wait(timeout=22), "Writer couldn't acquire lock - possible starvation" with release_count.get_lock(): - read_releases = release_count.value + read_releases = release_count.value - releases_before_contending assert read_releases < 3, f"Writer acquired after {read_releases} readers released - this indicates starvation" @@ -483,11 +492,14 @@ def acquire_lock( timeout: float = -1, blocking: bool = True, ready_event: EventType | None = None, + contending_event: EventType | None = None, ) -> None: if ready_event: ready_event.wait(timeout=10) lock = ReadWriteLock(lock_file, timeout=timeout, blocking=blocking) + if contending_event: + contending_event.set() # interpreter start-up is over, so a caller can time the contention itself with lock.read_lock() if mode == "read" else lock.write_lock(): acquired_event.set() if release_event: diff --git a/tests/test_read_write_fork.py b/tests/test_read_write_fork.py index 0e84b58e..7b5dca7f 100644 --- a/tests/test_read_write_fork.py +++ b/tests/test_read_write_fork.py @@ -10,10 +10,12 @@ from typing import Literal import pytest +from capability_marks import NEEDS_AUDIT_EVENTS, NEEDS_COLLECTED_FINALIZATION from filelock import ReadWriteLock +@NEEDS_AUDIT_EVENTS def test_read_write_lock_closes_idle_connections(tmp_path: Path) -> None: lock_path = tmp_path / "idle.db" connection_events = 0 @@ -45,6 +47,7 @@ def audit_hook( # pragma: no cover - interpreter audit hooks run with tracing d not Path("/dev/fd").is_dir() and not Path("/proc/self/fd").is_dir(), reason="no descriptor view", ) +@NEEDS_COLLECTED_FINALIZATION def test_read_write_lock_dropped_instances_leave_no_descriptors(tmp_path: Path) -> None: # pragma: needs fd-directory result = _run_fork_script(_dropped_instances_script(), [str(tmp_path)], timeout=10) @@ -52,6 +55,7 @@ def test_read_write_lock_dropped_instances_leave_no_descriptors(tmp_path: Path) @pytest.mark.skipif(not hasattr(os, "register_at_fork"), reason="requires fork transitions") # pragma: needs fork +@NEEDS_COLLECTED_FINALIZATION def test_async_read_write_lock_allows_finalizer_reentry(tmp_path: Path) -> None: result = _run_fork_script( _reentrant_finalizer_script(), @@ -76,7 +80,7 @@ class DerivedReadWriteLock(ReadWriteLock): @pytest.mark.parametrize( "audit_event", [ - pytest.param("sqlite3.connect", id="sqlite-connect"), + pytest.param("sqlite3.connect", marks=NEEDS_AUDIT_EVENTS, id="sqlite-connect"), pytest.param( "ctypes.dlsym", marks=pytest.mark.skipif( @@ -123,6 +127,7 @@ def test_read_write_lock_escrow_ignores_shared_ctypes_signatures(tmp_path: Path) pytest.param("close", id="close"), ], ) +@NEEDS_AUDIT_EVENTS def test_read_write_lock_rejects_same_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["acquire", "release", "close"] ) -> None: @@ -144,6 +149,7 @@ def test_read_write_lock_rejects_same_thread_operation_during_acquisition( pytest.param("close", id="close"), ], ) +@NEEDS_AUDIT_EVENTS def test_read_write_lock_serializes_other_thread_operation_during_acquisition( tmp_path: Path, operation: Literal["release", "close"] ) -> None: diff --git a/tests/test_read_write_unit.py b/tests/test_read_write_unit.py index 7acb9971..82ac9ad0 100644 --- a/tests/test_read_write_unit.py +++ b/tests/test_read_write_unit.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal import pytest +from capability_marks import NEEDS_GENERATOR_EXCEPTION_CONTEXT pytest.importorskip("sqlite3") @@ -618,6 +619,7 @@ def _init_then_fork( _Sub(lock_file) +@NEEDS_GENERATOR_EXCEPTION_CONTEXT def test_finish_connection_chains_rollback_then_close_error(lock_file: str, mocker: MockerFixture) -> None: lock = ReadWriteLock(lock_file, is_singleton=False) con = mocker.MagicMock() diff --git a/tests/test_soft_stale.py b/tests/test_soft_stale.py index 6fd1b5a1..348a7720 100644 --- a/tests/test_soft_stale.py +++ b/tests/test_soft_stale.py @@ -203,16 +203,16 @@ def test_symlinked_lock_file_is_not_followed(tmp_path: Path, lock_path: Path) -> def test_fifo_lock_file_does_not_block(lock_path: Path) -> None: - if sys.platform == "win32": # pragma: win32 cover - pytest.skip("os.mkfifo is unix-only") + if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover + pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # An attacker-placed FIFO must not stall the open; O_NONBLOCK makes the read bail instead of hang. os.mkfifo(lock_path) # pragma: win32 no cover assert SoftFileLock(lock_path).pid is None # pragma: win32 no cover def test_fifo_lock_file_with_attached_writer_self_heals(lock_path: Path) -> None: - if sys.platform == "win32": # pragma: win32 cover - pytest.skip("os.mkfifo is unix-only") + if sys.platform == "win32" or not CAPABILITIES["fifo"]: # pragma: win32 cover + pytest.skip("os.mkfifo is unavailable") # the platform arm also narrows so ty resolves os.mkfifo below # A same-UID peer can plant a FIFO with a writer attached so a non-blocking read would raise EAGAIN. The lstat # guard classifies it as a malformed lock before any open, so an aged FIFO self-heals like any other node. os.mkfifo(lock_path) # pragma: win32 no cover diff --git a/tests/test_strict_soft_failures.py b/tests/test_strict_soft_failures.py index 47b2a100..f31f30ac 100644 --- a/tests/test_strict_soft_failures.py +++ b/tests/test_strict_soft_failures.py @@ -31,6 +31,13 @@ reason="injects a fault into the dir_fd cleanup path, which a runtime without dir_fd never executes", ) +# Narrower still: GraalPy takes os.open relative to a directory descriptor but not os.link, so the backend never opens +# the directory these two faults are counted against. +_NEEDS_LINK_DIR_FD: Final[pytest.MarkDecorator] = pytest.mark.skipif( + not CAPABILITIES["link-dir-fd"], + reason="counts the directory the dir_fd link path opens, which a runtime without link dir_fd never opens", +) + if TYPE_CHECKING: from collections.abc import Iterator @@ -375,6 +382,7 @@ def fail_directory_close(fd: int) -> None: @_NEEDS_DIR_FD # pragma: needs dir-fd +@_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_directory_close_failure_leaves_both_claims(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) @@ -1315,6 +1323,7 @@ def test_strict_soft_acquires_without_dir_fd(tmp_path: Path, mocker: MockerFixtu @_NEEDS_DIR_FD # pragma: needs dir-fd +@_NEEDS_LINK_DIR_FD # pragma: needs link-dir-fd def test_strict_soft_held_link_and_directory_close_failure(tmp_path: Path, mocker: MockerFixture) -> None: lock_path = tmp_path / "resource.lock" _initialize_protocol(lock_path) diff --git a/tests/test_subclass_options.py b/tests/test_subclass_options.py index 9e33acf6..37219b49 100644 --- a/tests/test_subclass_options.py +++ b/tests/test_subclass_options.py @@ -7,6 +7,7 @@ from weakref import ref import pytest +from capability_marks import NEEDS_CLASS_COLLECTION from filelock import BaseAsyncFileLock, BaseFileLock, CloseErrorPolicy, ContextErrorPolicy @@ -217,6 +218,7 @@ def test_forwarding_subclass_singleton_rejects_changed_option( assert first.on_acquired is hook +@NEEDS_CLASS_COLLECTION def test_constructor_model_does_not_retain_dynamic_subclass(tmp_path: Path) -> None: lock_type = _dynamic_constructor() class_ref = ref(lock_type)