Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/+netbsd-fd.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
``ReadWriteLock`` and ``AsyncReadWriteLock`` use a validated private hard link when ``/dev/fd`` has no entry for the
database descriptor, supporting NetBSD's static descriptor directory beyond descriptor 63. The temporary location must
share a filesystem with the database. The symlink refusal test accepts NetBSD's error wording.
43 changes: 38 additions & 5 deletions src/filelock/_read_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import logging
import os
import pathlib
import shutil
import sqlite3
import stat
import sys
import tempfile
import threading
import time
from contextlib import contextmanager, suppress
Expand Down Expand Up @@ -151,6 +153,7 @@ def identity(path: pathlib.Path) -> _DatabaseIdentity | None:
class _ForkSafeConnection(sqlite3.Connection):
_creator_pid: int
_decrement_escrow: Callable[[sqlite3.Connection], None] | None
_database_directory: pathlib.Path | None

def __new__(
cls,
Expand All @@ -160,6 +163,7 @@ def __new__(
connection = super().__new__(cls)
connection._creator_pid = _GETPID()
connection._decrement_escrow = None
connection._database_directory = None
return connection

def close(self) -> None:
Expand All @@ -168,10 +172,17 @@ def close(self) -> None:
return
with _fork_transition():
sqlite3.Connection.close(self)
if (directory := self._database_directory) is not None: # pragma: needs posix-hard-link
shutil.rmtree(directory)
self._database_directory = None
if (decrement := self._decrement_escrow) is not None: # pragma: <3.12 cover # pragma: needs fork
self._decrement_escrow = None
decrement(self)

def retain_database_directory(self, directory: pathlib.Path | None) -> None:
"""Keep the private link until SQLite no longer checks its path."""
self._database_directory = directory

def acquire_escrow( # pragma: <3.12 cover # pragma: needs fork
self,
functions: tuple[Callable[[sqlite3.Connection], None], Callable[[sqlite3.Connection], None]] | None,
Expand All @@ -183,7 +194,7 @@ def acquire_escrow( # pragma: <3.12 cover # pragma: needs fork
self._decrement_escrow = decrement

def __del__(self) -> None:
with suppress(sqlite3.Error, RuntimeError):
with suppress(sqlite3.Error, RuntimeError, OSError):
self.close()


Expand Down Expand Up @@ -751,12 +762,34 @@ def _connect(database: str, *, factory: type[_ForkSafeConnection], timeout: floa
_FORKED_DATABASES.note_sqlite_use()
# A symlink at the path would make SQLite open, lock, or create its target, so connect through a descriptor that
# refuses one. SQLite opens its own descriptor on the file inside connect(), so ours can close after the call.
fd: Final[int] = _open_lock_database(database)
target: Final[str] = database if sys.platform == "win32" else f"{_FD_DIR}/{fd}"
fd: int | None = _open_lock_database(database)
target = pathlib.Path(database if sys.platform == "win32" else f"{_FD_DIR}/{fd}")
directory: pathlib.Path | None = None
try:
return sqlite3.connect(target, check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout)
# NetBSD's static /dev/fd exposes only descriptors 0-63; a private hard link also pins the validated inode.
if sys.platform != "win32" and not os.access(target, os.F_OK): # pragma: needs posix-hard-link
directory = pathlib.Path(tempfile.mkdtemp(prefix=".filelock-"))
target = directory / "lock.db"
os.link(database, target, follow_symlinks=False)
linked: Final = target.stat(follow_symlinks=False)
opened: Final = os.fstat(fd)
if (linked.st_dev, linked.st_ino) != (opened.st_dev, opened.st_ino):
msg = f"lock database changed while opening: {database!r}"
raise OSError(msg)
# Closing any descriptor on this inode clears the process's POSIX locks; SQLite must open after this close.
os.close(fd)
fd = None
connection: Final = sqlite3.connect(
os.fspath(target), check_same_thread=False, factory=factory, cached_statements=0, timeout=timeout
)
connection.retain_database_directory(directory)
directory = None
return connection
finally:
os.close(fd)
if fd is not None: # pragma: needs posix-hard-link
os.close(fd)
if directory is not None: # pragma: needs posix-hard-link
shutil.rmtree(directory)


def _open_lock_database(database: str) -> int:
Expand Down
6 changes: 5 additions & 1 deletion tasks/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ def _unreadable_file() -> Iterator[Path]:
probe.chmod(0o600)


_LINK_FOLLOW_SYMLINKS: Final[bool] = _honors_link_follow_symlinks()


#: Capability -> whether this runtime provides it. Tests gate their skipif on this same mapping.
CAPABILITIES: Final[dict[str, bool]] = {
"fork": hasattr(os, "fork") and hasattr(os, "register_at_fork"),
Expand Down Expand Up @@ -274,7 +277,8 @@ def _unreadable_file() -> Iterator[Path]:
"sqlite3": find_spec("sqlite3") is not None,
# A source consumer may run the suite unmeasured, and a forked child then has nothing to flush.
"coverage": find_spec("coverage") is not None,
"link-follow-symlinks": _honors_link_follow_symlinks(),
"link-follow-symlinks": _LINK_FOLLOW_SYMLINKS,
"posix-hard-link": sys.platform != "win32" and _LINK_FOLLOW_SYMLINKS,
"tracemalloc-object-traceback": _reports_object_tracebacks(),
# Only the tox env that installs a released filelock sets this.
"old-client": bool(os.environ.get("FILELOCK_OLD_CLIENT_PATH")),
Expand Down
4 changes: 4 additions & 0 deletions tasks/coverage_pragmas.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ def _extend(config: TConfigurable, option: str, patterns: list[str]) -> None:

#: Modules a missing capability makes unrunnable in full; marking every line would restate one module-level gate.
_CAPABILITY_MODULES: Final[dict[str, tuple[str, ...]]] = {
"posix-hard-link": (
"*/tests/test_read_write_fd_fallback.py",
"*\\tests\\test_read_write_fd_fallback.py",
),
"hard-link": (
"*/tests/test_strict_soft*.py",
"*/tests\\test_strict_soft*.py",
Expand Down
3 changes: 2 additions & 1 deletion tests/test_read_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ctypes
import os
import re
import sys
import time
from multiprocessing import Event, Process, Value, set_start_method
Expand Down Expand Up @@ -398,7 +399,7 @@ def test_read_write_lock_refuses_a_symlinked_path(tmp_path: Path) -> None:
except OSError as error: # pragma: no cover - platform policy can deny symlink creation
pytest.skip(str(error))
# Construction opens a connection, so the refusal lands there; acquiring reopens and refuses the same way.
with pytest.raises(OSError, match=r"symbolic link|symlink"):
with pytest.raises(OSError, match=re.escape(str(link))):
ReadWriteLock(link, is_singleton=False).acquire_read()
assert not target.exists()

Expand Down
112 changes: 112 additions & 0 deletions tests/test_read_write_fd_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

import os
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Final, Literal

import pytest
from capabilities import CAPABILITIES

pytest.importorskip("sqlite3")

from filelock import AsyncReadWriteLock, ReadWriteLock
from tests.read_write_helpers import assert_read_write_lock_state

if TYPE_CHECKING:
from pytest_mock import MockerFixture

pytestmark: Final = [
pytest.mark.requires_hard_links,
pytest.mark.skipif(
not CAPABILITIES["posix-hard-link"],
reason="private database aliases need POSIX hard links without following symlinks",
),
]


@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
def test_missing_descriptor_path_preserves_contention(database: Path, mode: Literal["read", "write"]) -> None:
lock: Final = ReadWriteLock(database, is_singleton=False)
with (lock.read_lock if mode == "read" else lock.write_lock)():
assert_read_write_lock_state(str(database), "write", available=False)


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
async def test_missing_descriptor_path_preserves_async_contention(
database: Path, mode: Literal["read", "write"]
) -> None:
lock: Final = AsyncReadWriteLock(database, is_singleton=False)
async with (lock.read_lock if mode == "read" else lock.write_lock)():
assert_read_write_lock_state(str(database), "write", available=False)
await lock.close()


def test_missing_descriptor_path_removes_alias_after_release(database: Path, created_directories: list[Path]) -> None:
lock: Final = ReadWriteLock(database, is_singleton=False)
lock.acquire_read()
assert [directory.exists() for directory in created_directories] == [False, True]
lock.release()
assert [directory.exists() for directory in created_directories] == [False, False]


@pytest.mark.parametrize(
"boundary",
[pytest.param("os.link", id="link"), pytest.param("sqlite3.connect", id="connect")],
)
def test_missing_descriptor_path_cleans_up_after_failure(
database: Path, mocker: MockerFixture, boundary: str, created_directories: list[Path]
) -> None:
mocker.patch(boundary, autospec=True, side_effect=OSError("cannot open database"))
with pytest.raises(OSError, match="cannot open database"):
ReadWriteLock(database, is_singleton=False)
assert [directory.exists() for directory in created_directories] == [False]


@pytest.mark.parametrize("replacement_kind", [pytest.param("file", id="file"), pytest.param("symlink", id="symlink")])
def test_missing_descriptor_path_rejects_replaced_database(
database: Path, tmp_path: Path, mocker: MockerFixture, replacement_kind: str, created_directories: list[Path]
) -> None:
replacement: Final = tmp_path / "replacement.db"
if replacement_kind == "file":
replacement.touch()
else:
replacement.symlink_to(tmp_path / "victim.db")
link: Final = os.link

def replace_and_link(source: str, destination: Path, *, follow_symlinks: bool) -> None:
replacement.replace(source)
link(source, destination, follow_symlinks=follow_symlinks)

mocker.patch("os.link", autospec=True, side_effect=replace_and_link)
with pytest.raises(OSError, match="database changed"):
ReadWriteLock(database, is_singleton=False)
assert ([directory.exists() for directory in created_directories], (tmp_path / "victim.db").exists()) == (
[False],
False,
)


@pytest.fixture
def database(tmp_path: Path) -> Path:
return tmp_path / "lock.db"


@pytest.fixture
def created_directories(mocker: MockerFixture) -> list[Path]:
directories: Final[list[Path]] = []
mkdtemp: Final = tempfile.mkdtemp

def create_directory(*, prefix: str) -> str:
directory: Final = mkdtemp(prefix=prefix)
directories.append(Path(directory))
return directory

mocker.patch("tempfile.mkdtemp", autospec=True, side_effect=create_directory)
return directories


@pytest.fixture(autouse=True)
def missing_descriptor_path(mocker: MockerFixture) -> None:
mocker.patch("os.access", autospec=True, return_value=False)
Loading